Compare commits

...
2 Commits
Author SHA1 Message Date
david 6a667d1019 uploading 2026-01-05 16:07:45 -05:00
david a475dfeab6 Major overhaul to the theme and now functioning. 2026-01-05 16:07:30 -05:00
5218 changed files with 1643933 additions and 13229 deletions
+2
View File
@@ -0,0 +1,2 @@
10546
10547
@@ -0,0 +1,359 @@
import hashlib
import os
import requests
import time
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse, urljoin
import uuid
import re
import threading
class BunkrDownloader:
def __init__(self, download_folder, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, headers=None, max_workers=5, translations=None):
self.download_folder = download_folder
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.session = requests.Session()
self.headers = headers or {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
'Referer': 'https://bunkr.site/',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'Accept-Language': 'en-US,en;q=0.9',
}
self.cancel_requested = False # Flag to indicate if a cancellation request has been made
self.executor = ThreadPoolExecutor(max_workers=max_workers) # Thread pool executor for concurrent downloads
self.total_files = 0
self.completed_files = 0
self.max_downloads = 5 # Valor por defecto
self.log_messages = [] # Cola para almacenar mensajes de log
self.notification_interval = 10 # Intervalo de notificación en segundos
self.start_notification_thread()
self.translations = translations or {}
def start_notification_thread(self):
def notify_user():
while not self.cancel_requested:
if self.log_messages:
# Enviar todos los mensajes acumulados
if self.log_callback:
self.log_callback("\n".join(self.log_messages))
self.log_messages.clear()
time.sleep(self.notification_interval)
# Iniciar un hilo para notificaciones periódicas
notification_thread = threading.Thread(target=notify_user, daemon=True)
notification_thread.start()
def tr(self, key):
# Obtener la traducción para la clave dada
return self.translations.get(key, key)
def log(self, message_key, url=None):
message = self.tr(message_key)
domain = urlparse(url).netloc if url else "General"
full_message = f"{domain}: {message}"
self.log_messages.append(full_message) # Agregar mensaje a la cola
def request_cancel(self):
self.cancel_requested = True
self.log("Download has been cancelled.")
self.shutdown_executor()
def shutdown_executor(self):
self.executor.shutdown(wait=False)
self.log("Executor shut down.")
def clean_filename(self, filename):
return re.sub(r'[<>:"/\\|?*\u200b]', '_', filename)
def get_consistent_folder_name(self, url, default_name):
# Genera un hash de la URL para crear un nombre único y consistente
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
folder_name = f"{default_name}_{url_hash}"
return self.clean_filename(folder_name)
def download_file(self, url_media, ruta_carpeta, file_id):
if self.cancel_requested:
self.log("Descarga cancelada", url=url_media)
return
file_name = os.path.basename(urlparse(url_media).path)
file_path = os.path.join(ruta_carpeta, file_name)
if os.path.exists(file_path):
self.log(f"El archivo ya existe, omitiendo: {file_path}")
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
return
max_attempts = 3
delay = 1
for attempt in range(max_attempts):
try:
self.log(f"Intentando descargar {url_media} (Intento {attempt + 1}/{max_attempts})")
response = self.session.get(url_media, headers=self.headers, stream=True)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
# Descargar el archivo en fragmentos
with open(file_path, 'wb') as file:
for chunk in response.iter_content(chunk_size=65536): # Fragmentos de 64KB
if self.cancel_requested:
self.log("Descarga cancelada durante la descarga del archivo.", url=url_media)
file.close()
os.remove(file_path)
return
file.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
self.update_progress_callback(downloaded_size, total_size, file_id=file_id, file_path=file_path)
self.log("Archivo descargado", url=url_media)
# Notificar al usuario al completar la descarga
if self.log_callback:
self.log_callback(f"Descarga completada: {file_name}")
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
break
except requests.RequestException as e:
if response.status_code == 429:
self.log(f"Límite de tasa excedido. Reintentando después de {delay} segundos.")
time.sleep(delay)
delay *= 2 # Retroceso exponencial para limitación de tasa
else:
self.log(f"Error al descargar de {url_media}: {e}. Intento {attempt + 1} de {max_attempts}", url=url_media)
if attempt < max_attempts - 1:
time.sleep(3)
def descargar_post_bunkr(self, url_post):
try:
self.log(f"Iniciando descarga para el post: {url_post}")
# Si se trata de una URL tipo '/f/', seguimos el flujo en dos pasos:
if '/f/' in url_post:
self.log("Detectado URL tipo '/f/'. Procediendo a extraer el enlace intermedio.")
# Paso 1: Accedemos a la URL original para obtener el primer enlace (intermedio)
response = self.session.get(url_post, headers=self.headers)
if response.status_code != 200:
self.log(f"Error al acceder al post {url_post}: Estado {response.status_code}")
return
soup = BeautifulSoup(response.text, 'html.parser')
first_anchor = soup.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold flex-1 ic-download-01 ic-before before:text-lg'
})
if not first_anchor or 'href' not in first_anchor.attrs:
self.log("No se encontró el primer enlace de descarga en la página original.")
return
intermediate_url = first_anchor['href']
self.log(f"Enlace intermedio encontrado: {intermediate_url}")
# Paso 2: Accedemos a la URL intermedia para extraer el enlace final de descarga
intermediate_response = self.session.get(intermediate_url, headers=self.headers)
if intermediate_response.status_code != 200:
self.log(f"Error al acceder a la URL intermedia: {intermediate_url} (Estado {intermediate_response.status_code})")
return
soup2 = BeautifulSoup(intermediate_response.text, 'html.parser')
# Buscamos la etiqueta <p> con la clase esperada y, dentro, el <a> con el enlace final
p_tag = soup2.find('p', class_="mt-3 text-center")
if not p_tag:
self.log("No se encontró la etiqueta <p> con clase 'mt-3 text-center' en la página intermedia.")
return
download_anchor = p_tag.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold ic-download-01 ic-before before:text-lg'
})
if not download_anchor or 'href' not in download_anchor.attrs:
self.log("No se encontró el enlace de descarga final en la página intermedia.")
return
final_download_url = download_anchor['href']
self.log(f"Enlace de descarga final encontrado: {final_download_url}")
# Creamos la carpeta de destino para este post
file_name = "bunkr_post"
folder_name = self.get_consistent_folder_name(url_post, file_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
# Preparamos la lista de medios con el enlace final
media_urls = [(final_download_url, ruta_carpeta)]
else:
# Lógica original para posts que contienen imágenes y videos
response = self.session.get(url_post, headers=self.headers)
if response.status_code != 200:
self.log(f"Error al acceder al post {url_post}: Estado {response.status_code}")
return
soup = BeautifulSoup(response.text, 'html.parser')
# Extraer y sanitizar el nombre de la carpeta para el post
file_name_tag = soup.find('h1', {'class': 'truncate'})
if file_name_tag:
file_name = file_name_tag.text.strip()
file_name = self.clean_filename(file_name)[:50] # Limitar a 50 caracteres
else:
file_name = "bunkr_post"
folder_name = self.get_consistent_folder_name(url_post, file_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
media_urls = []
# Buscar imágenes en etiquetas <figure>
media_divs = soup.find_all('figure', {'class': 'relative rounded-lg overflow-hidden flex justify-center items-center aspect-video bg-soft'})
for div in media_divs:
img_tags = div.find_all('img')
for img_tag in img_tags:
if 'src' in img_tag.attrs:
img_url = img_tag['src']
self.log(f"URL de imagen encontrada: {img_url}")
media_urls.append((img_url, ruta_carpeta))
# Buscar videos: se recorre cada div que pueda contener el enlace intermedio de descarga
video_divs = soup.find_all('div', {'class': 'flex w-full md:w-auto gap-4'})
self.log(f"Se encontraron {len(video_divs)} divs de video.")
for video_div in video_divs:
self.log("Buscando enlace de página de descarga en el div de video.")
download_page_link = video_div.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold flex-1 ic-download-01 ic-before before:text-lg'
})
if download_page_link and 'href' in download_page_link.attrs:
video_page_url = download_page_link['href']
self.log(f"URL de la página de descarga encontrada: {video_page_url}. Accediendo ahora.")
video_page_response = self.session.get(video_page_url, headers=self.headers)
self.log(f"Estado de la respuesta de la página de video: {video_page_response.status_code} para {video_page_url}")
if video_page_response.status_code == 200:
video_page_soup = BeautifulSoup(video_page_response.text, 'html.parser')
self.log("Buscando enlace de descarga real en la página de video.")
download_link = video_page_soup.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold ic-download-01 ic-before before:text-lg'
})
if download_link and 'href' in download_link.attrs:
video_url = download_link['href']
self.log(f"URL de descarga de video encontrada: {video_url}")
media_urls.append((video_url, ruta_carpeta))
else:
self.log(f"No se encontró enlace de descarga en la página de video: {video_page_url}")
else:
self.log(f"Error al acceder a la página de video: {video_page_url} con estado {video_page_response.status_code}")
# Proceder a la descarga de todos los medios encontrados
self.total_files = len(media_urls)
if media_urls: # Solo proceder si hay URLs para descargar
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=self.max_downloads) as executor:
futures = [executor.submit(self.download_file, url, folder, str(uuid.uuid4())) for url, folder in media_urls]
for future in as_completed(futures):
if self.cancel_requested:
self.log("Cancelando descargas restantes.")
break
future.result()
self.log("Descarga iniciada para todos los medios.")
if self.enable_widgets_callback:
self.enable_widgets_callback()
except Exception as e:
self.log(f"Error al procesar el post {url_post}: {e}")
if self.enable_widgets_callback:
self.enable_widgets_callback()
def descargar_perfil_bunkr(self, url_perfil):
try:
self.log(f"Iniciando descarga para el perfil: {url_perfil}")
response = self.session.get(url_perfil, headers=self.headers)
self.log(f"Código de estado de la respuesta: {response.status_code} para {url_perfil}")
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
# Extraer y sanitizar el nombre de la carpeta para el perfil
file_name_tag = soup.find('h1', {'class': 'truncate'})
if file_name_tag:
folder_name = file_name_tag.text.strip()
else:
folder_name = "bunkr_profile"
# Usar el nuevo método para obtener un nombre de carpeta consistente
folder_name = self.get_consistent_folder_name(url_perfil, folder_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
# Find media URLs in the profile
media_urls = []
grid_div = soup.find('div', {'class': 'grid gap-4 grid-cols-repeat [--size:11rem] lg:[--size:14rem] grid-images'})
if grid_div:
links = grid_div.find_all('a', {'class': 'after:absolute after:z-10 after:inset-0'})
total_links = len(links)
for idx, link in enumerate(links):
if self.cancel_requested:
self.log("Cancelling remaining downloads.")
break
# Resolve relative URLs to full URLs
image_page_url = urljoin(url_perfil, link['href'])
self.log(f"Processing media page URL: {image_page_url}")
# Visit the page to get the media URL
image_response = self.session.get(image_page_url, headers=self.headers)
if image_response.status_code == 200:
image_soup = BeautifulSoup(image_response.text, 'html.parser')
# Search for image URL
media_tag = image_soup.select_one("figure.relative img[class='w-full h-full absolute opacity-20 object-cover blur-sm z-10']")
if media_tag and 'src' in media_tag.attrs:
media_url = urljoin(image_page_url, media_tag['src']) # Resolve media URL
self.log(f"Found image URL: {media_url}")
media_urls.append((media_url, ruta_carpeta))
# Search for video URL
video_tag = image_soup.select_one("video#player")
if video_tag and 'src' in video_tag.attrs:
video_url = urljoin(image_page_url, video_tag['src']) # Resolve video URL
self.log(f"Found video URL: {video_url}")
media_urls.append((video_url, ruta_carpeta))
else:
source_tag = video_tag.find('source') if video_tag else None
if source_tag and 'src' in source_tag.attrs:
video_url = urljoin(image_page_url, source_tag['src']) # Resolve video URL from source
self.log(f"Found video URL from source: {video_url}")
media_urls.append((video_url, ruta_carpeta))
self.total_files = len(media_urls)
futures = [self.executor.submit(self.download_file, url, folder, str(uuid.uuid4())) for url, folder in media_urls]
# Only after all futures are done, enable widgets again
for future in as_completed(futures):
if self.cancel_requested:
self.log("Cancelling remaining downloads.")
break
future.result()
self.log("Download completed for all media.")
if self.enable_widgets_callback:
self.enable_widgets_callback() # Only enable after all downloads are done
else:
self.log(f"Failed to access the profile {url_perfil}: Status {response.status_code}")
except Exception as e:
self.log(f"Failed to access the profile {url_perfil}: {e}")
if self.enable_widgets_callback:
self.enable_widgets_callback()
def set_max_downloads(self, max_downloads):
self.max_downloads = max_downloads
@@ -0,0 +1,705 @@
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Semaphore
from urllib.parse import quote_plus, urlencode, urljoin, urlparse
import os
import re
import requests
import threading
import time
import sqlite3
from ..storage import LocalStorage
class Downloader:
def __init__(self, download_folder, max_workers=5, log_callback=None,
enable_widgets_callback=None, update_progress_callback=None,
update_global_progress_callback=None, headers=None,
max_retries=999999, retry_interval=1.0, stream_read_timeout=10,
download_images=True, download_videos=True, download_compressed=True,
tr=None, folder_structure='default', rate_limit_interval=1.0,
storage=None, force_overwrite=False):
self.storage = storage if storage else LocalStorage()
self.download_folder = download_folder
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.force_overwrite = force_overwrite
# self.on_file_complete_callback removed
self.cancel_requested = threading.Event()
self.headers = headers or {
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
'Referer': 'https://coomer.st/',
"Accept": "text/css"
}
self.media_counter = 0
self.session = requests.Session()
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
self.rate_limit = Semaphore(self.max_workers)
self.domain_locks = defaultdict(lambda: Semaphore(self.max_workers))
self.domain_last_request = defaultdict(float)
self.rate_limit_interval = rate_limit_interval
self.download_mode = "multi"
self.video_extensions = ('.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.wmv', '.m4v')
self.image_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff')
self.document_extensions = ('.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx')
self.compressed_extensions = ('.zip', '.rar', '.7z', '.tar', '.gz')
self.download_images = download_images
self.download_videos = download_videos
self.download_compressed = download_compressed
self.futures = []
self.total_files = 0
self.completed_files = 0
self.skipped_files = []
self.failed_files = []
self.start_time = None
self.tr = tr
self.shutdown_called = False
self.folder_structure = folder_structure
self.failed_retry_count = {}
self.max_retries = max_retries
self.retry_interval = retry_interval
self.file_lock = threading.Lock()
self.post_attachment_counter = defaultdict(int)
self.subdomain_cache = {}
self.subdomain_locks = defaultdict(threading.Lock)
self.stream_read_timeout = stream_read_timeout
db_folder = os.path.join("resources", "config")
os.makedirs(db_folder, exist_ok=True)
self.db_path = os.path.join(db_folder, "downloads.db")
self.db_lock = threading.Lock()
self.init_db()
self.load_download_cache()
def init_db(self):
self.db_connection = sqlite3.connect(self.db_path, check_same_thread=False)
self.db_cursor = self.db_connection.cursor()
self.db_cursor.execute("""
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_url TEXT UNIQUE,
file_path TEXT,
file_size INTEGER,
user_id TEXT,
post_id TEXT,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
self.db_connection.commit()
def load_download_cache(self):
with self.db_lock:
self.db_cursor.execute("SELECT media_url, file_path, file_size FROM downloads")
rows = self.db_cursor.fetchall()
self.download_cache = {row[0]: (row[1], row[2]) for row in rows}
def log(self, message):
if self.log_callback:
self.log_callback(self.tr(message) if self.tr else message)
def set_download_mode(self, mode, max_workers):
if mode == 'queue':
max_workers = 1
self.download_mode = mode
self.max_workers = max_workers
if self.executor:
self.executor.shutdown(wait=True)
self.executor = ThreadPoolExecutor(max_workers=max_workers)
self.rate_limit = Semaphore(max_workers)
self.log(f"Updated download mode to {mode} with max_workers = {max_workers}")
def set_retry_settings(self, max_retries, retry_interval):
self.max_retries = max_retries
self.rate_limit_interval = retry_interval
def request_cancel(self):
self.cancel_requested.set()
self.log(self.tr("Download cancellation requested."))
for future in self.futures:
future.cancel()
def shutdown_executor(self):
if not self.shutdown_called:
self.shutdown_called = True
if self.executor:
self.executor.shutdown(wait=True)
if self.enable_widgets_callback:
self.enable_widgets_callback()
self.log(self.tr("All downloads completed or cancelled."))
def safe_request(self, url, max_retries=None, headers=None):
if max_retries is None:
max_retries = self.max_retries
if headers is None:
headers = self.headers
parsed = urlparse(url)
domain = parsed.netloc
path = parsed.path
for attempt in range(max_retries + 1):
if self.cancel_requested.is_set():
return None
with self.domain_locks[domain]:
elapsed_time = time.time() - self.domain_last_request[domain]
if elapsed_time < self.rate_limit_interval:
time.sleep(self.rate_limit_interval - elapsed_time)
try:
self.domain_last_request[domain] = time.time()
response = self.session.get(url, stream=True, headers=headers, timeout=self.stream_read_timeout)
sc = response.status_code
if sc in (403, 404) and ("coomer" in domain or "kemono" in domain):
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"{sc} - probing subdomains")
with self.subdomain_locks[path]:
if path in self.subdomain_cache:
alt_url = self.subdomain_cache[path]
else:
alt_url = self._find_valid_subdomain(url)
self.subdomain_cache[path] = alt_url
if alt_url != url:
found = urlparse(alt_url).netloc
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Subdomain found: {found}")
response = self.session.get(alt_url, stream=True, headers=headers)
response.raise_for_status()
return response
else:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status="Exhausted subdomains")
return None
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
status_code = getattr(e.response, 'status_code', None)
if status_code in (429, 500, 502, 503, 504):
self.log(log_message)
time.sleep(self.retry_interval)
elif status_code not in (403, 404):
url_display = getattr(e.request, 'url', url)
if len(url_display) > 60:
url_display = url_display[:60] + "..."
self.log(self.tr("Intento {attempt}/{max_retries_val}: Error al acceder a {url} - {error}").format(
attempt=attempt + 1, max_retries_val=max_retries + 1, url=url_display, error=e))
if attempt < max_retries:
time.sleep(self.retry_interval)
else:
if isinstance(e, requests.exceptions.ReadTimeout):
self.log(self.tr("Intento {attempt}/{max_retries_val}: Read timeout ({stream_timeout}s) - Reintentando...").format(
attempt=attempt + 1,
max_retries_val=max_retries + 1,
stream_timeout=self.stream_read_timeout
))
time.sleep(self.retry_interval)
else:
log_message = self.tr("Intento {attempt}/{max_retries_val}: Error {status_code} - Reintentando...").format(
attempt=attempt + 1, max_retries_val=max_retries + 1, status_code=status_code)
if status_code in (403, 404) and ("coomer" in domain or "kemono" in domain) and attempt == max_retries:
self.log(self.tr("Fallo final al acceder a {url} con error {status_code}").format(url=url, status_code=status_code))
return None
def _find_valid_subdomain(self, url, max_subdomains=10):
parsed = urlparse(url)
original_path = parsed.path
path = original_path
if not original_path.startswith("/data/"):
path = ("/data" + original_path) if not original_path.startswith("/data") else original_path
host = parsed.netloc
if "coomer" in host:
base_domains = ["coomer.st"]
elif "kemono" in host:
base_domains = ["kemono.cr", "kemono.su"]
else:
base_domains = [host]
for base in base_domains:
for i in range(1, max_subdomains + 1):
domain = f"n{i}.{base}"
test_url = parsed._replace(netloc=domain, path=path).geturl()
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Testing subdomain: {domain}")
try:
resp = self.session.get(test_url, headers=self.headers,
timeout=self.stream_read_timeout, stream=True)
if resp.status_code == 200:
return test_url
else:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Invalid subdomain: {domain}")
except requests.exceptions.ReadTimeout:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Timeout in: {domain}")
except Exception:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Invalid subdomain: {domain}")
return url
def fetch_user_posts(self, site, user_id, service, query=None, specific_post_id=None, initial_offset=0, log_fetching=True):
all_posts = []
offset = initial_offset
user_id_encoded = quote_plus(user_id)
while True:
if self.cancel_requested.is_set():
return all_posts
api_url = f"https://{site}/api/v1/{service}/user/{user_id_encoded}/posts"
url_query = {"o": offset}
if query not in (None, "", 0, "0"):
url_query["q"] = query
api_url += "?" + urlencode(url_query)
if log_fetching:
self.log(self.tr("Fetching user posts from {api_url}", api_url=api_url))
try:
response = self.session.get(api_url, headers=self.headers)
if response.status_code == 400:
self.log(self.tr("End of posts at offset {offset}.", offset=offset))
break
response.raise_for_status()
try:
posts_data = response.json()
except ValueError as e:
self.log(self.tr("Error al parsear JSON: {e}", e=e))
break
if isinstance(posts_data, dict) and 'data' in posts_data:
posts = posts_data['data']
else:
posts = posts_data
if not posts:
break
if specific_post_id:
post = next((p for p in posts if p['id'] == specific_post_id), None)
if post:
return [post]
all_posts.extend(posts)
offset += 50
except Exception as e:
self.log(self.tr("Error fetching user posts: {e}", e=e))
break
if specific_post_id:
return [post for post in all_posts if post['id'] == specific_post_id]
return all_posts
def get_filename(self, media_url, post_id=None, post_name=None, attachment_index=1, post_time=None):
base_name = os.path.basename(media_url).split('?')[0]
name_no_ext, extension = os.path.splitext(base_name)
if not hasattr(self, 'file_naming_mode'):
self.file_naming_mode = 0
mode = self.file_naming_mode
def sanitize(name):
sanitized = self.sanitize_filename(name)
return sanitized.strip()
if mode == 0:
sanitized = sanitize(name_no_ext)
if not sanitized:
sanitized = "file"
final_name = f"{sanitized}_{attachment_index}{extension}"
elif mode == 1:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
short_hash = f"{hash(media_url) & 0xFFFF:04x}"
final_name = f"{sanitized_post}_{attachment_index}_{short_hash}{extension}"
elif mode == 2:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
if post_id:
final_name = f"{sanitized_post} - {post_id}_{attachment_index}{extension}"
else:
final_name = f"{sanitized_post}_{attachment_index}{extension}"
elif mode == 3:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
sanitized_time = sanitize(post_time or "")
short_hash = f"{hash(media_url) & 0xFFFF:04x}"
final_name = f"{sanitized_time} - {sanitized_post}_{attachment_index}_{short_hash}{extension}"
else:
final_name = sanitize(name_no_ext) + extension
return final_name
def process_post(self, post, site):
base = f"https://{site}/"
def _full(path):
if not path:
return None
p = path if str(path).startswith('/') else f'/{path}'
return urljoin(base, p)
media_urls = []
f = post.get('file') or {}
u = _full(f.get('path') or f.get('url') or f.get('name'))
if u:
media_urls.append(u)
for att in (post.get('attachments') or []):
u = _full(att.get('path') or att.get('url') or att.get('name'))
if u:
media_urls.append(u)
return media_urls
def sanitize_filename(self, filename):
return re.sub(r'[<>:"/\\|?*]', '_', filename)
def get_media_folder(self, extension, user_id, post_id=None):
if extension in self.video_extensions:
folder_name = "videos"
elif extension in self.image_extensions:
folder_name = "images"
elif extension in self.document_extensions:
folder_name = "documents"
elif extension in self.compressed_extensions:
folder_name = "compressed"
else:
folder_name = "other"
if self.folder_structure == 'post_number' and post_id:
media_folder = os.path.join(self.download_folder, user_id, f'post_{post_id}', folder_name)
else:
media_folder = os.path.join(self.download_folder, user_id, folder_name)
return media_folder
def process_media_element(self, media_url, user_id, post_id=None,
post_name=None, post_time=None, download_id=None):
if self.cancel_requested.is_set():
return
extension = os.path.splitext(media_url)[1].lower()
if (extension in self.image_extensions and not self.download_images) or \
(extension in self.video_extensions and not self.download_videos) or \
(extension in self.compressed_extensions and not self.download_compressed):
self.log(f"Skipping {media_url} due to settings.")
return
if post_id:
self.post_attachment_counter[post_id] += 1
attachment_index = self.post_attachment_counter[post_id]
else:
attachment_index = 1
filename = self.get_filename(media_url, post_id=post_id, post_name=post_name, post_time=post_time,
attachment_index=attachment_index)
media_folder = self.get_media_folder(extension, user_id, post_id)
# Ensure directory exists via storage
self.storage.makedirs(media_folder)
final_path = os.path.normpath(os.path.join(media_folder, filename))
tmp_path = final_path + ".tmp"
# Handle Overwrite
if self.force_overwrite:
if self.storage.exists(final_path):
self.log(f"Overwrite enabled: Deleting existing file {final_path}")
self.storage.delete(final_path)
else:
# Check DB
if media_url in self.download_cache:
self.log(f"File from {media_url} is in DB, skipping.")
with self.file_lock:
self.skipped_files.append(final_path)
return
# Check Storage Existence (if not in DB but on disk/share)
if self.storage.exists(final_path):
full_log_path = self.storage.get_full_path(final_path)
self.log(f"File {full_log_path} exists on storage, skipping.")
self.download_cache[media_url] = (final_path, self.storage.get_size(final_path))
return
full_log_path = self.storage.get_full_path(final_path)
self.log(f"Starting download from {media_url} to {full_log_path}")
for attempt in range(self.max_retries + 1):
if self.cancel_requested.is_set():
if self.storage.exists(tmp_path):
self.storage.delete(tmp_path)
self.log(f"Download cancelled from {media_url}")
return
response = self.safe_request(media_url, max_retries=self.max_retries)
if response is None:
if attempt < self.max_retries:
self.log(f"Initial request failed for {media_url}. Resuming download in {self.retry_interval}s. (Attempt {attempt+1}/{self.max_retries + 1})")
time.sleep(self.retry_interval)
continue
else:
break
try:
try:
total_size = int(response.headers.get('content-length', 0))
except Exception as e:
self.log(f"Error getting total size: {e}")
total_size = 0
self.start_time = time.time()
# We need a stateful tracker for progress bar
tracker = {"downloaded": 0}
def tick(bytes_read):
if self.cancel_requested.is_set():
raise Exception("Cancellation Requested")
tracker["downloaded"] += bytes_read
if self.update_progress_callback:
elapsed = time.time() - self.start_time
speed = tracker["downloaded"] / elapsed if elapsed > 0 else 0
eta = (total_size - tracker["downloaded"]) / speed if speed > 0 else 0
self.update_progress_callback(tracker["downloaded"], total_size,
file_id=download_id,
file_path=tmp_path,
speed=speed,
eta=eta)
# SAVE using Storage abstraction
self.storage.save(tmp_path, response, progress_callback=tick)
# Rename tmp to final
with self.file_lock:
self.storage.rename(tmp_path, final_path)
self.completed_files += 1
self.log(f"Download success from {media_url}")
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
with self.db_lock:
self.db_cursor.execute(
"""INSERT OR REPLACE INTO downloads (media_url, file_path, file_size, user_id, post_id)
VALUES (?, ?, ?, ?, ?)""",
(media_url, final_path, total_size, user_id, post_id)
)
self.db_connection.commit()
self.download_cache[media_url] = (final_path, total_size)
return
except Exception as e:
if str(e) == "Cancellation Requested":
if self.storage.exists(tmp_path):
self.storage.delete(tmp_path)
self.log(f"Download cancelled from {media_url}")
return
self.log(f"Error downloading {media_url}: {e}")
if attempt < self.max_retries:
time.sleep(self.retry_interval)
continue
self.log(f"Failed to download {media_url} after {self.max_retries + 1} total download attempts.")
with self.file_lock:
self.failed_files.append(media_url)
def get_remote_file_size(self, media_url, filename):
try:
response = requests.head(media_url, allow_redirects=True)
if response.status_code == 200:
size = int(response.headers.get('Content-Length', 0))
return media_url, filename, size
else:
self.log(self.tr(f"Failed to get size for {filename}: HTTP {response.status_code}"))
return media_url, filename, None
except Exception as e:
self.log(self.tr(f"Error getting size for {filename}: {e}"))
return media_url, filename, None
def download_media(self, site, user_id, service, query=None, download_all=False, initial_offset=0):
try:
self.log(self.tr("Starting download process..."))
posts = self.fetch_user_posts(
site, user_id, service,
query=query,
initial_offset=initial_offset,
log_fetching=download_all
)
if not posts:
self.log(self.tr("No posts found for this user."))
return
if not download_all:
posts = posts[:50]
self.total_files = 0
for post in posts:
current_post_id = post.get('id') or "unknown_id"
title = post.get('title') or ""
media_urls = self.process_post(post, site)
for media_url in media_urls:
ext = os.path.splitext(media_url)[1].lower()
if (ext in self.image_extensions and not self.download_images) or \
(ext in self.video_extensions and not self.download_videos) or \
(ext in self.compressed_extensions and not self.download_compressed):
continue
self.total_files += 1
futures = []
for post in posts:
current_post_id = post.get('id') or "unknown_id"
title = post.get('title') or ""
time = post.get('published') or ""
media_urls = self.process_post(post, site)
for media_url in media_urls:
ext = os.path.splitext(media_url)[1].lower()
if (ext in self.image_extensions and not self.download_images) or \
(ext in self.video_extensions and not self.download_videos) or \
(ext in self.compressed_extensions and not self.download_compressed):
continue
if self.download_mode == 'queue':
self.process_media_element(
media_url,
user_id,
post_id=current_post_id,
post_name=title,
post_time=time
)
else:
future = self.executor.submit(
self.process_media_element,
media_url,
user_id,
current_post_id,
title,
time,
media_url
)
futures.append(future)
if self.download_mode == 'multi':
for future in as_completed(futures):
if self.cancel_requested.is_set():
break
except Exception as e:
self.log(self.tr(f"Error during download: {e}"))
finally:
self.shutdown_executor()
def download_single_post(self, site, post_id, service, user_id):
try:
post = self.fetch_user_posts(site, user_id, service, specific_post_id=post_id)
if not post:
self.log(self.tr("No post found for this ID."))
return
media_urls = self.process_post(post[0], site)
futures = []
grouped_media_urls = defaultdict(list)
for media_url in media_urls:
grouped_media_urls[post[0]['id']].append(media_url)
self.total_files = len(media_urls)
self.completed_files = 0
for post_id, media_urls in grouped_media_urls.items():
for media_url in media_urls:
if self.download_mode == 'queue':
self.process_media_element(media_url, user_id, post_id)
else:
future = self.executor.submit(self.process_media_element, media_url, user_id, post_id)
futures.append(future)
if self.download_mode == 'multi':
for future in as_completed(futures):
if self.cancel_requested.is_set():
break
except Exception as e:
self.log(self.tr(f"Error during download: {e}"))
finally:
self.shutdown_executor()
def fetch_single_post(self, site, post_id, service):
api_url = f"https://{site}/api/v1/{service}/post/{post_id}"
self.log(self.tr(f"Fetching post from {api_url}"))
try:
with self.rate_limit:
response = self.session.get(api_url, headers=self.headers)
response.raise_for_status()
return response.json()
except Exception as e:
self.log(self.tr(f"Error fetching post: {e}"))
return None
def clear_database(self):
with self.db_lock:
self.db_cursor.execute("DELETE FROM downloads")
self.db_connection.commit()
self.log(self.tr("Database cleared."))
def update_max_downloads(self, new_max):
if self.executor:
self.executor.shutdown(wait=True)
self.max_workers = new_max
self.executor = ThreadPoolExecutor(max_workers=new_max)
self.rate_limit = Semaphore(new_max)
self.log(f"Updated max_workers to {new_max}")
@@ -0,0 +1,293 @@
import re
import uuid
import requests
import os
import time
import datetime
from bs4 import BeautifulSoup
from urllib.parse import urljoin, quote
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from requests.exceptions import ChunkedEncodingError
import logging
# from tkinter import messagebox, simpledialog (Removed for backend)
class EromeDownloader:
# ... (rest of class)
def _create_folder_interactive(self, folder_name): # Renamed or logic changed
# Instead of asking, just try or fail
try:
os.makedirs(folder_name, exist_ok=True)
return folder_name
except Exception as e:
if self.log_callback:
self.log_callback(f"Error creating folder {folder_name}: {e}")
raise e
class EromeDownloader:
def __init__(self, root, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, download_images=True, download_videos=True, headers=None, language="en", is_profile_download=False, direct_download=False, tr=None, max_workers=5):
self.root = root
self.session = requests.Session()
self.headers = {k: str(v).encode('ascii', 'ignore').decode('ascii') for k, v in (headers or {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}).items()}
self.log_messages = [] # Store log messages
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.download_images = download_images
self.download_videos = download_videos
self.cancel_requested = False
self.language = language
self.executor = ThreadPoolExecutor(max_workers=max_workers) # Thread pool for concurrent downloads
self.total_files = 0
self.completed_files = 0
self.is_profile_download = is_profile_download
self.direct_download = direct_download # Option for direct downloads without folder creation
self.tr = tr if tr else lambda x, **kwargs: x.format(**kwargs) # Translation function
def request_cancel(self):
self.cancel_requested = True
self.log(self.tr("Download cancelled"))
if self.is_profile_download:
self.enable_widgets_callback()
def log(self, message):
if self.log_callback is not None:
self.log_callback(message)
self.log_messages.append(message)
def shutdown_executor(self):
self.executor.shutdown(wait=False)
self.log(self.tr("Executor shut down."))
if self.is_profile_download:
self.enable_widgets_callback()
@staticmethod
def clean_filename(filename):
return re.sub(r'[<>:"/\\|?*]', '_', filename.split('?')[0])
def create_folder(self, folder_name):
try:
os.makedirs(folder_name, exist_ok=True)
except OSError as e:
self.log(self.tr("Error creating folder: {error}", error=e))
# In backend mode, we cannot ask for a new name. We just proceed or fail.
# Returning the original folder_name might cause subsequent errors, but it's better than hanging.
return folder_name
def download_file(self, url, file_path, resource_type, file_id=None, max_retries=999999):
if self.cancel_requested:
return
# Evita sobreescrituras y descargas duplicadas
if os.path.exists(file_path):
self.log(self.tr("File already exists, skipping: {file_path}",
file_path=file_path))
return
os.makedirs(os.path.dirname(file_path), exist_ok=True)
self.log(self.tr("Start downloading {resource_type}: {file_path}",
resource_type=resource_type, file_path=file_path))
retries = 0
while retries <= max_retries:
try:
with requests.get(url, headers=self.headers,
stream=True, timeout=15) as response:
if response.status_code != 200:
self.log(self.tr("Error downloading {resource_type}, "
"status code: {status_code}",
resource_type=resource_type,
status_code=response.status_code))
break
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = last_update = time.time()
with open(file_path, "wb") as f:
for chunk in response.iter_content(chunk_size=65536):
if self.cancel_requested:
return
f.write(chunk)
downloaded_size += len(chunk)
# Envía actualización cada 0.5 s máx.
now = time.time()
if now - last_update >= 0.5:
elapsed = now - start_time
speed = downloaded_size / elapsed if elapsed else 0
eta = ((total_size - downloaded_size) / speed
if speed else None)
self.update_progress_callback(
downloaded_size, total_size,
file_id=file_id,
file_path=file_path,
speed=speed,
eta=eta
)
last_update = now
# ─────── Fin de la descarga ───────
elapsed = time.time() - start_time
final_speed = total_size / elapsed if elapsed else 0
# 1· Fuerza la barra al 100 % (sin status)
self.update_progress_callback(
total_size, total_size,
file_id=file_id,
file_path=file_path,
speed=final_speed,
eta=0
)
# 2· Notifica “Completed” (no altera la barra)
self.update_progress_callback(
total_size, total_size,
file_id=file_id,
file_path=file_path,
status="Completed"
)
# Contabiliza y avanza la barra global
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(
self.completed_files, self.total_files
)
self.log(self.tr("Download successful: {resource_type}, "
"{file_path}",
resource_type=resource_type,
file_path=file_path))
break # Éxito ⇒ sal del bucle
except (requests.exceptions.ChunkedEncodingError,
requests.exceptions.ConnectionError,
requests.exceptions.Timeout) as e:
retries += 1
self.log(self.tr("Error downloading {resource_type}, "
"attempt {retries}/{max_retries}: {error}",
resource_type=resource_type,
retries=retries,
max_retries=max_retries,
error=e))
if retries == max_retries:
self.log(self.tr("Max retries reached. Failed to "
"download {resource_type}: {file_path}",
resource_type=resource_type,
file_path=file_path))
def process_album_page(self, page_url, base_folder, download_images=True, download_videos=True):
try:
if self.cancel_requested:
return
self.log(self.tr("Processing album URL: {page_url}", page_url=page_url))
response = requests.get(page_url, headers=self.headers)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
if not self.direct_download:
folder_name = self.clean_filename(soup.find('h1').text if soup.find('h1') else self.tr("Unknown Album"))
folder_path = self.create_folder(os.path.join(base_folder, folder_name))
else:
folder_path = base_folder # Use the base folder directly
media_urls = []
seen_urls = set()
# --- vídeos ---
if download_videos:
for video in soup.find_all('video'):
source = video.find('source')
if source:
abs_video_src = urljoin(page_url, source['src'])
if abs_video_src in seen_urls: # ya lo tenemos
continue
seen_urls.add(abs_video_src)
video_name = os.path.join(
folder_path,
self.clean_filename(os.path.basename(abs_video_src))
)
media_urls.append(
(abs_video_src, video_name, 'Video')
)
# --- imágenes ---
if download_images:
for div in soup.select('div.img'):
img = div.find('img', attrs={'data-src': True})
if img:
abs_img_src = urljoin(page_url, img['data-src'])
if abs_img_src in seen_urls:
continue
seen_urls.add(abs_img_src)
img_name = os.path.join(
folder_path,
self.clean_filename(os.path.basename(abs_img_src))
)
media_urls.append(
(abs_img_src, img_name, 'Image')
)
self.total_files += len(media_urls)
futures = [self.executor.submit(self.download_file, url, file_path, resource_type, str(uuid.uuid4())) for url, file_path, resource_type in media_urls]
for future in as_completed(futures):
if self.cancel_requested:
self.log(self.tr("Cancelling remaining downloads."))
break
future.result()
self.log(self.tr("Album download complete: {folder_name}", folder_name=folder_name) if not self.direct_download else self.tr("Album download complete"))
if not self.is_profile_download:
self.enable_widgets_callback()
else:
self.log(self.tr("Error accessing page: {page_url}, status code: {status_code}", page_url=page_url, status_code=response.status_code))
if not self.is_profile_download:
self.enable_widgets_callback()
finally:
if not self.is_profile_download:
self.enable_widgets_callback()
self.export_logs()
def process_profile_page(self, url, download_folder, download_images, download_videos):
try:
if self.cancel_requested:
return
self.log(self.tr("Processing profile URL: {url}", url=url))
response = requests.get(url, headers=self.headers)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
username = soup.find('h1', class_='username').text.strip() if soup.find('h1', class_='username') else self.tr("Unknown Profile")
base_folder = self.create_folder(os.path.join(download_folder, self.clean_filename(username)))
album_links = soup.find_all('a', class_='album-link')
for album_link in album_links:
album_href = album_link.get('href')
album_full_url = urljoin(url, album_href)
self.process_album_page(album_full_url, base_folder, download_images, download_videos)
self.log(self.tr("Profile download complete: {username}", username=username))
self.enable_widgets_callback()
else:
self.log(self.tr("Error accessing page: {url}, status code: {status_code}", url=url, status_code=response.status_code))
self.enable_widgets_callback()
finally:
if not self.is_profile_download:
self.enable_widgets_callback()
self.export_logs()
def export_logs(self):
log_folder = "resources/config/logs/"
Path(log_folder).mkdir(parents=True, exist_ok=True)
log_file_path = Path(log_folder) / f"log_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
try:
with open(log_file_path, 'w') as file:
file.write("\n".join(self.log_messages))
self.log(self.tr("Logs exported successfully to {path}", path=log_file_path))
except Exception as e:
self.log(self.tr(f"Failed to export logs: {e}"))
@@ -0,0 +1,112 @@
import requests
from bs4 import BeautifulSoup
import os
import threading
from concurrent.futures import ThreadPoolExecutor
# from app import progress_manager (Removed)
class Jpg5Downloader:
def __init__(self, url, carpeta_destino, log_callback=None, tr=None, update_progress_callback=None, update_global_progress_callback=None, max_workers=3):
self.url = url
self.carpeta_destino = carpeta_destino
self.log_callback = log_callback
self.tr = tr if tr else lambda x: x # Función de traducción por defecto
self.cancel_requested = threading.Event() # Usar un evento para manejar la cancelación
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.max_workers = max_workers
self.progress_manager = None # progress_manager
def log(self, message):
if self.log_callback:
self.log_callback(message)
else:
print(message)
def request_cancel(self):
self.cancel_requested.set() # Activar el evento de cancelación
self.log(self.tr("Descarga cancelada por el usuario."))
def descargar_imagenes(self):
if not os.path.exists(self.carpeta_destino):
os.makedirs(self.carpeta_destino)
self.log(self.tr(f"Iniciando descarga desde: {self.url}"))
respuesta = requests.get(self.url)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
soup = BeautifulSoup(respuesta.content, 'html.parser')
divs = soup.find_all('div', class_='list-item c8 gutter-margin-right-bottom')
total_divs = len(divs)
self.log(self.tr(f"Total de elementos a procesar: {total_divs}"))
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = []
for i, div in enumerate(divs):
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
enlaces = div.find_all('a', class_='image-container --media')
for enlace in enlaces:
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
futures.append(executor.submit(self.descargar_enlace, enlace, i, total_divs))
for future in futures:
future.result() # Esperar a que todas las descargas terminen
def descargar_enlace(self, enlace, i, total_divs):
try:
media_url = enlace['href']
self.log(self.tr(f"Procesando enlace: {media_url}"))
media_respuesta = requests.get(media_url)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
media_soup = BeautifulSoup(media_respuesta.content, 'html.parser')
header_content = media_soup.find('div', class_='header-content-right')
if header_content:
btn_descarga = header_content.find('a', class_='btn btn-download default')
if btn_descarga and 'href' in btn_descarga.attrs:
descarga_url = btn_descarga['href']
self.log(self.tr(f"Descargando desde: {descarga_url}"))
img_respuesta = requests.get(descarga_url, stream=True)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
img_nombre = os.path.basename(descarga_url)
img_path = os.path.join(self.carpeta_destino, img_nombre)
total_size = int(img_respuesta.headers.get('content-length', 0))
downloaded_size = 0
with open(img_path, 'wb') as f:
for chunk in img_respuesta.iter_content(chunk_size=1024):
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
f.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
self.update_progress_callback(downloaded_size, total_size)
self.log(self.tr(f"Imagen descargada: {img_nombre}"))
if self.update_global_progress_callback:
self.update_global_progress_callback(i + 1, total_divs)
else:
self.log(self.tr("No se encontró el enlace de descarga."))
else:
self.log(self.tr("No se encontró la clase 'header-content-right'."))
except Exception as e:
self.log(self.tr(f"Error al procesar el enlace: {e}"))
@@ -0,0 +1,137 @@
import os
import json
import re
import queue
from pathlib import Path
from bs4 import BeautifulSoup
from urllib.parse import urlparse
import cloudscraper
from concurrent.futures import ThreadPoolExecutor
class SimpCity:
def __init__(self, download_folder, max_workers=5, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, tr=None):
self.download_folder = download_folder
self.max_workers = max_workers
self.descargadas = set()
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.cancel_requested = False
self.total_files = 0
self.completed_files = 0
self.download_queue = queue.Queue()
self.scraper = cloudscraper.create_scraper(browser={'browser': 'chrome', 'platform': 'windows', 'mobile': False})
self.tr = tr
# Selectors from original crawler
self.title_selector = "h1[class=p-title-value]"
self.posts_selector = "div[class*=message-main]"
self.post_content_selector = "div[class*=message-userContent]"
self.images_selector = "img[class*=bbImage]"
self.videos_selector = "video source"
self.iframe_selector = "iframe[class=saint-iframe]"
self.attachments_block_selector = "section[class=message-attachments]"
self.attachments_selector = "a"
self.next_page_selector = "a[class*=pageNav-jump--next]"
self.cookies_path = "resources/config/cookies/simpcity.json"
self.set_cookies()
def log(self, message):
if self.log_callback:
self.log_callback(message)
def sanitize_folder_name(self, name):
return re.sub(r'[<>:"/\\|?*]', '_', name)
def set_cookies(self):
if os.path.exists(self.cookies_path):
with open(self.cookies_path, "r", encoding="utf-8") as f:
cookies = json.load(f)
if isinstance(cookies, dict):
cookies = [cookies]
for c in cookies:
if isinstance(c, dict) and "name" in c and "value" in c:
self.scraper.cookies.set(c["name"], c["value"])
def fetch_page(self, url):
try:
response = self.scraper.get(url)
if response.status_code == 200:
return BeautifulSoup(response.content, 'html.parser')
else:
self.log(self.tr(f"Error: {response.status_code} al acceder a {url}"))
return None
except Exception as e:
self.log(self.tr(f"Error al acceder a {url}: {e}"))
return None
def save_file(self, file_url, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
response = self.scraper.get(file_url, stream=True)
if response.status_code == 200:
with open(path, 'wb') as file:
for chunk in response.iter_content(1024):
file.write(chunk)
self.log(self.tr(f"Archivo descargado: {path}"))
else:
self.log(self.tr(f"Error al descargar {file_url}: {response.status_code}"))
def process_post(self, post_content, download_folder):
# Procesar imágenes
images = post_content.select(self.images_selector)
for img in images:
src = img.get('src')
if src:
file_name = os.path.basename(urlparse(src).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(src, file_path)
# Procesar videos
videos = post_content.select(self.videos_selector)
for video in videos:
src = video.get('src')
if src:
file_name = os.path.basename(urlparse(src).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(src, file_path)
# Procesar archivos adjuntos
attachments_block = post_content.select_one(self.attachments_block_selector)
if attachments_block:
attachments = attachments_block.select(self.attachments_selector)
for attachment in attachments:
href = attachment.get('href')
if href:
file_name = os.path.basename(urlparse(href).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(href, file_path)
def process_page(self, url):
soup = self.fetch_page(url)
if not soup:
return
title_element = soup.select_one(self.title_selector)
folder_name = self.sanitize_folder_name(title_element.text.strip()) if title_element else 'SimpCity_Download'
download_folder = os.path.join(self.download_folder, folder_name)
os.makedirs(download_folder, exist_ok=True)
message_inners = soup.select(self.posts_selector)
for post in message_inners:
post_content = post.select_one(self.post_content_selector)
if post_content:
self.process_post(post_content, download_folder)
next_page = soup.select_one(self.next_page_selector)
if next_page:
next_page_url = next_page.get('href')
if next_page_url:
self.process_page(self.base_url + next_page_url)
def download_images_from_simpcity(self, url):
self.log(self.tr(f"Procesando hilo: {url}"))
self.process_page(url)
self.log(self.tr("Descarga completada."))
@@ -0,0 +1,240 @@
import threading
import datetime
import re
import os
from urllib.parse import urlparse, parse_qs
from .downloader_core.bunkr import BunkrDownloader
from .downloader_core.downloader import Downloader
from .downloader_core.erome import EromeDownloader
from .downloader_core.simpcity import SimpCity
from .downloader_core.jpg5 import Jpg5Downloader
from .samba_manager import SambaManager
from .storage import SambaStorage
from .config import settings
# Dummy translator
def tr(key, **kwargs):
return key.format(**kwargs)
def extract_ck_parameters(url):
match = re.search(r"/(?P<service>[^/?]+)(/user/(?P<user>[^/?]+)(/post/(?P<post>[^/?]+))?)?", url.path)
if match:
return match.group("service"), match.group("user"), match.group("post")
return None, None, None
def extract_ck_query(url):
query = parse_qs(url.query)
q = query.get("q")[0] if query.get("q") and len(query.get("q")) > 0 else "0"
o = query.get("o")[0] if query.get("o") and len(query.get("o")) > 0 else "0"
return q, int(o) if str.isdigit(o) else 0
class DownloaderManager:
def __init__(self):
self.active_downloader = None
self.download_thread = None
self.logs = []
self.progress = {
"status": "Idle",
"active": False,
"downloaded": 0,
"total": 0,
"speed": 0,
"eta": 0,
"current_file": ""
}
self.download_folder = os.path.join(os.getcwd(), "downloads")
os.makedirs(self.download_folder, exist_ok=True)
self.samba_manager = None
def log_callback(self, message):
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
self.logs.append(f"[{timestamp}] {message}")
if len(self.logs) > 1000:
self.logs.pop(0)
def update_progress_callback(self, downloaded, total, **kwargs):
self.progress["downloaded"] = downloaded
self.progress["total"] = total
if "speed" in kwargs:
self.progress["speed"] = kwargs["speed"]
if "eta" in kwargs:
self.progress["eta"] = kwargs["eta"]
if "file_path" in kwargs:
self.progress["current_file"] = os.path.basename(kwargs["file_path"])
def enable_widgets_callback(self):
# Called when a single downloader finishes
# We don't set active=False here because we might have more URLs
pass
def start_download(self, urls, options):
if self.progress["active"]:
raise Exception("Download already in progress")
self.logs = []
self.progress["active"] = True
self.progress["status"] = "Starting..."
self.progress["downloaded"] = 0
self.progress["total"] = 0
self.download_thread = threading.Thread(target=self._run_download, args=(urls, options))
self.download_thread.start()
def _run_download(self, urls, options):
try:
for i, url in enumerate(urls):
# Check for cancellation between URLs
if self.progress["status"] == "Cancelling...":
self.log_callback("Queue processing cancelled.")
break
self.log_callback(f"Processing URL {i+1}/{len(urls)}: {url}")
try:
self._process_single_url(url, options)
except Exception as e:
self.log_callback(f"Error processing URL {url}: {e}")
# Continue to next URL? Yes, usually desired behavior for bulk.
except Exception as e:
self.log_callback(f"Fatal error in download thread: {e}")
self.progress["status"] = "Error"
finally:
self.progress["active"] = False
self.progress["status"] = "Finished"
if self.samba_manager:
self.samba_manager.close()
self.samba_manager = None
self.active_downloader = None
def _process_single_url(self, url, options):
# Options
download_images = options.get("download_images", True)
download_videos = options.get("download_videos", True)
max_workers = options.get("max_workers", 3)
force_overwrite = options.get("force_overwrite", False)
parsed_url = urlparse(url)
target = None
args = ()
# Cleanup previous samba manager if exists (though we try to clean up in finally)
if self.samba_manager:
self.samba_manager.close()
self.samba_manager = None
# Dispatch Logic
if "erome.com" in url:
self.log_callback("Starting Erome Download")
is_profile = "/a/" not in url
self.active_downloader = EromeDownloader(
download_folder=self.download_folder,
log_callback=self.log_callback,
enable_widgets_callback=self.enable_widgets_callback,
update_progress_callback=self.update_progress_callback,
tr=tr
)
if "/a/" in url:
target = self.active_downloader.process_album_page
args = (url, self.download_folder, download_images, download_videos)
else:
target = self.active_downloader.process_profile_page
args = (url, self.download_folder, download_images, download_videos)
elif re.search(r"https?://([a-z0-9-]+\.)?bunkr\.[a-z]{2,}", url):
self.log_callback("Starting Bunkr Download")
self.active_downloader = BunkrDownloader(
download_folder=self.download_folder,
log_callback=self.log_callback,
enable_widgets_callback=self.enable_widgets_callback,
update_progress_callback=self.update_progress_callback,
tr=tr
)
if any(sub in url for sub in ["/v/", "/i/", "/f/"]):
target = self.active_downloader.descargar_post_bunkr
args = (url,)
else:
target = self.active_downloader.descargar_perfil_bunkr
args = (url,)
elif parsed_url.netloc in ["coomer.st", "kemono.cr", "coomer.su", "kemono.su"]:
self.log_callback("Starting Coomer/Kemono Download (Direct SMB)")
try:
self.samba_manager = SambaManager(
settings.samba_server_ip,
"isolation",
settings.samba_username,
settings.samba_password
)
self.log_callback(f"Connected to SMB Share: isolation at {settings.samba_server_ip}")
storage = SambaStorage(self.samba_manager, base_path="videos/coomerparty")
except Exception as e:
self.log_callback(f"Failed to connect to SMB: {e}")
raise e
self.active_downloader = Downloader(
download_folder="",
max_workers=max_workers,
log_callback=self.log_callback,
enable_widgets_callback=self.enable_widgets_callback,
update_progress_callback=self.update_progress_callback,
tr=tr,
download_images=download_images,
download_videos=download_videos,
storage=storage,
force_overwrite=force_overwrite
)
site = parsed_url.netloc
service, user, post = extract_ck_parameters(parsed_url)
if not service or not user:
raise Exception("Invalid Coomer/Kemono URL")
if post:
target = self.active_downloader.download_single_post
args = (site, post, service, user)
else:
query, offset = extract_ck_query(parsed_url)
target = self.active_downloader.download_media
args = (site, user, service, query, True, offset)
elif "simpcity.cr" in url:
self.log_callback("Starting SimpCity Download")
self.active_downloader = SimpCity(
download_folder=self.download_folder,
log_callback=self.log_callback,
enable_widgets_callback=self.enable_widgets_callback,
update_progress_callback=self.update_progress_callback,
tr=tr
)
target = self.active_downloader.download_images_from_simpcity
args = (url,)
elif "jpg5.su" in url:
self.log_callback("Starting Jpg5 Download")
self.active_downloader = Jpg5Downloader(
url=url,
carpeta_destino=self.download_folder,
log_callback=self.log_callback,
tr=tr
)
target = self.active_downloader.descargar_imagenes
args = ()
else:
raise Exception(f"Unsupported URL: {url}")
# Execute
self.progress["status"] = "Running"
target(*args)
def cancel_download(self):
self.progress["status"] = "Cancelling..."
self.log_callback("Cancellation requested...")
if self.active_downloader and hasattr(self.active_downloader, 'request_cancel'):
self.active_downloader.request_cancel()
manager = DownloaderManager()
+2 -1
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from . import models
from .database import SessionLocal, engine, get_db
from .routers import samba, comics, duplicates
from .routers import samba, comics, duplicates, downloader
models.Base.metadata.create_all(bind=engine)
@@ -27,6 +27,7 @@ app.add_middleware(
app.include_router(samba.router, prefix="/samba", tags=["samba"])
app.include_router(comics.router, prefix="/comics", tags=["comics"])
app.include_router(duplicates.router, prefix="/duplicates", tags=["duplicates"])
app.include_router(downloader.router, prefix="/downloader", tags=["downloader"])
@@ -0,0 +1,43 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from ..downloader_manager import manager
router = APIRouter()
class DownloadRequest(BaseModel):
urls: List[str]
download_images: bool = True
download_videos: bool = True
max_workers: int = 3
force_overwrite: bool = False
class StatusResponse(BaseModel):
active: bool
status: str
downloaded: int
total: int
speed: float
eta: float
current_file: str
@router.post("/start")
def start_download(request: DownloadRequest):
try:
manager.start_download(request.urls, request.model_dump())
return {"message": "Download started"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/cancel")
def cancel_download():
manager.cancel_download()
return {"message": "Cancellation requested"}
@router.get("/status", response_model=StatusResponse)
def get_status():
return manager.progress
@router.get("/logs", response_model=List[str])
def get_logs():
return manager.logs
@@ -1,12 +1,18 @@
import os
import io
import mimetypes
import zipfile
import hashlib
import shutil
from fastapi import APIRouter, UploadFile, File, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from ..samba_manager import SambaManager
from ..config import settings
import os
import io
import mimetypes
import zipfile
# CBZ Cache Configuration
CACHE_DIR = "temp_cbz"
os.makedirs(CACHE_DIR, exist_ok=True)
class RenameRequest(BaseModel):
old_path: str
@@ -14,6 +20,32 @@ class RenameRequest(BaseModel):
router = APIRouter()
def get_cached_cbz_path(share_name: str, path: str):
"""Returns the local path to the cached CBZ file, downloading it if necessary."""
file_hash = hashlib.md5(f"{share_name}:{path}".encode()).hexdigest()
cache_path = os.path.join(CACHE_DIR, f"{file_hash}.cbz")
if not os.path.exists(cache_path):
# Check if we need to clean up cache? (Simple FIFO or size check could be added later)
# For now, we download.
manager = SambaManager(
settings.samba_server_ip,
share_name,
username=settings.samba_username,
password=settings.samba_password,
)
try:
with open(cache_path, "wb") as f:
manager.download_file(path, f)
except Exception as e:
# Clean up partial file
if os.path.exists(cache_path):
os.remove(cache_path)
raise e
finally:
manager.close()
return cache_path
@router.get("/list/{share_name}")
def list_files(share_name: str, path: str = "/"):
@@ -36,8 +68,23 @@ def delete_file(share_name: str, path: str):
username=settings.samba_username,
password=settings.samba_password,
)
result = manager.delete_file(path)
manager.close()
try:
# Check if it's a directory
attributes = manager.get_attributes(path)
if isinstance(attributes, dict) and "error" in attributes:
# If getting attributes fails, try deleting as file (maybe simple file)
# or return error. But standard delete_file tries deleteFiles.
result = manager.delete_file(path)
elif attributes.isDirectory:
result = manager.delete_directory_recursive(path)
else:
result = manager.delete_file(path)
except Exception as e:
result = {"error": str(e)}
finally:
manager.close()
return result
@@ -122,47 +169,39 @@ def view_file(share_name: str, path: str, request: Request):
@router.get("/cbz/list/{share_name}")
def list_cbz_contents(share_name: str, path: str):
manager = SambaManager(
settings.samba_server_ip,
share_name,
username=settings.samba_username,
password=settings.samba_password,
)
file_obj = io.BytesIO()
manager.download_file(path, file_obj)
manager.close()
file_obj.seek(0)
image_files = []
with zipfile.ZipFile(file_obj, 'r') as zip_ref:
for file_info in zip_ref.infolist():
if file_info.filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')):
image_files.append(file_info.filename)
return sorted(image_files)
try:
cache_path = get_cached_cbz_path(share_name, path)
image_files = []
with zipfile.ZipFile(cache_path, 'r') as zip_ref:
for file_info in zip_ref.infolist():
if file_info.filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')):
image_files.append(file_info.filename)
return sorted(image_files)
except Exception as e:
return Response(content=str(e), status_code=500)
@router.get("/cbz/image/{share_name}")
def get_cbz_image(share_name: str, path: str, image_name: str):
manager = SambaManager(
settings.samba_server_ip,
share_name,
username=settings.samba_username,
password=settings.samba_password,
)
file_obj = io.BytesIO()
manager.download_file(path, file_obj)
manager.close()
file_obj.seek(0)
with zipfile.ZipFile(file_obj, 'r') as zip_ref:
with zip_ref.open(image_name) as image_file:
image_data = io.BytesIO(image_file.read())
media_type, _ = mimetypes.guess_type(image_name)
if media_type is None:
media_type = "application/octet-stream"
try:
cache_path = get_cached_cbz_path(share_name, path)
return StreamingResponse(image_data, media_type=media_type)
# Open zip from cache
# We need to return a stream. We can read the specific file into BytesIO.
# This is fast since it's local disk read.
with zipfile.ZipFile(cache_path, 'r') as zip_ref:
with zip_ref.open(image_name) as image_file:
image_data = io.BytesIO(image_file.read())
media_type, _ = mimetypes.guess_type(image_name)
if media_type is None:
media_type = "application/octet-stream"
return StreamingResponse(image_data, media_type=media_type)
except Exception as e:
return Response(content=str(e), status_code=500)
@router.put("/files/rename/{share_name}")
@@ -7,9 +7,16 @@ class SambaManager:
def __init__(self, server_ip, share_name, username, password, client_machine_name="smbclient"):
self.server_ip = server_ip
self.share_name = share_name
self.username = username
self.password = password
self.client_machine_name = client_machine_name
self.conn = SMBConnection(username, password, client_machine_name, "server_name", use_ntlm_v2=True)
self.conn.connect(server_ip, 139)
def clone(self):
"""Creates a new independent connection with the same credentials."""
return SambaManager(self.server_ip, self.share_name, self.username, self.password, self.client_machine_name)
def list_path(self, current_dir_path="/"):
try:
files = self.conn.listPath(self.share_name, current_dir_path)
@@ -45,6 +52,7 @@ class SambaManager:
def upload_file(self, path, file_obj):
try:
logger.info(f"Uploading file to share '{self.share_name}' at path: '{path}'")
self.conn.storeFile(self.share_name, path, file_obj)
return {"success": True}
except Exception as e:
+170
View File
@@ -0,0 +1,170 @@
import os
import shutil
import time
from abc import ABC, abstractmethod
from .samba_manager import SambaManager
class Storage(ABC):
@abstractmethod
def exists(self, path): pass
@abstractmethod
def get_size(self, path): pass
@abstractmethod
def makedirs(self, path): pass
@abstractmethod
def rename(self, src, dst): pass
@abstractmethod
def delete(self, path): pass
@abstractmethod
def save(self, path, response, progress_callback=None): pass
@abstractmethod
def get_full_path(self, path): pass
class LocalStorage(Storage):
def exists(self, path):
return os.path.exists(path)
def get_size(self, path):
if os.path.exists(path):
return os.path.getsize(path)
return 0
def makedirs(self, path):
os.makedirs(path, exist_ok=True)
def rename(self, src, dst):
if os.path.exists(dst):
os.remove(dst)
os.rename(src, dst)
def delete(self, path):
if os.path.exists(path):
os.remove(path)
def get_full_path(self, path):
return os.path.abspath(path)
def save(self, path, response, progress_callback=None):
# Local save supports chunked writing which allows easy progress update
# We assume response is a requests response object with stream=True
mode = 'wb'
# Check if we are resuming? LocalStorage in legacy code handled this outside save.
# But here we are encapsulating the write loop.
# If headers Range is set, we might need 'ab'.
# But for simplicity, we assume 'wb' unless specific logic is added.
# Downloader logic passed 'ab' if resuming.
# We can inspect response.request.headers['Range']?
# Or just take a mode arg.
# For now, let's implement standard write. Resume support can be added if needed via mode arg.
downloaded = 0
total_size = int(response.headers.get('content-length', 0))
with open(path, 'wb') as f:
for chunk in response.iter_content(chunk_size=1048576):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if progress_callback:
progress_callback(len(chunk))
class ProgressReader:
def __init__(self, raw_stream, callback):
self.raw_stream = raw_stream
self.callback = callback
def read(self, size=-1):
chunk = self.raw_stream.read(size)
if chunk and self.callback:
self.callback(len(chunk))
return chunk
class SambaStorage(Storage):
def __init__(self, samba_manager: SambaManager, base_path=""):
self.smb = samba_manager
self.base_path = base_path # e.g. "videos/coomerparty"
def _full_path(self, path):
# path comes from Downloader as "user/img/file.jpg"
# we append to base_path
if self.base_path:
return f"{self.base_path}/{path}".replace("\\", "/")
return path.replace("\\", "/")
def get_full_path(self, path):
# Return a string representing the full smb path
inner_path = self._full_path(path)
return f"smb://{self.smb.server_ip}/{self.smb.share_name}/{inner_path}"
def _exec(self, func, *args):
# Helper to execute with a fresh connection
conn = self.smb.clone()
try:
return func(conn, *args)
finally:
conn.close()
def exists(self, path):
def _op(smb):
try:
attr = smb.get_attributes(self._full_path(path))
if isinstance(attr, dict) and "error" in attr:
return False
return True
except:
return False
return self._exec(_op)
def get_size(self, path):
def _op(smb):
try:
attr = smb.get_attributes(self._full_path(path))
return attr.file_size
except:
return 0
return self._exec(_op)
def makedirs(self, path):
full = self._full_path(path)
parts = full.split("/")
def _op(smb):
current = ""
for part in parts:
if not part: continue
current = f"{current}/{part}" if current else part
try:
smb.create_directory(current)
except:
pass # Ignore if exists
self._exec(_op)
def rename(self, src, dst):
full_src = self._full_path(src)
full_dst = self._full_path(dst)
def _op(smb):
try:
# Check if dst exists, delete if so
try:
smb.get_attributes(full_dst)
smb.delete_file(full_dst)
except:
pass
smb.rename_file(full_src, full_dst)
except Exception as e:
raise e
self._exec(_op)
def delete(self, path):
def _op(smb):
smb.delete_file(self._full_path(path))
self._exec(_op)
def save(self, path, response, progress_callback=None):
full_path = self._full_path(path)
# Use ProgressReader to wrap response.raw
reader = ProgressReader(response.raw, progress_callback)
def _op(smb):
smb.upload_file(full_path, reader)
self._exec(_op)
@@ -2217,4 +2217,45 @@ b'fe534d4240000000000000000600000000000000000000002e00000000000000ed490300a9ca88
2025-12-29 14:32:47,928 - INFO - Processing series: a_molester_took_everything_from_me
2025-12-29 14:32:47,930 - INFO - Moved a_molester_took_everything_from_me_chapter_1.cbz to /comics/manga/a_molester_took_everything_from_me
2025-12-29 14:32:47,930 - INFO - Comics organization process finished.
2026-01-05 10:21:36,455 - INFO - Starting comics organization process...
2026-01-05 10:21:36,505 - INFO - Found 0 series to process.
2026-01-05 10:21:36,505 - INFO - Comics organization process finished.
2026-01-05 10:22:26,121 - INFO - Starting comics organization process...
2026-01-05 10:22:26,205 - INFO - Found 0 series to process.
2026-01-05 10:22:26,206 - INFO - Comics organization process finished.
2026-01-05 10:23:24,808 - INFO - Starting comics organization process...
2026-01-05 10:23:24,852 - INFO - Found 0 series to process.
2026-01-05 10:23:24,853 - INFO - Comics organization process finished.
2026-01-05 10:23:58,054 - INFO - Starting comics organization process...
2026-01-05 10:23:58,119 - INFO - Found 0 series to process.
2026-01-05 10:23:58,119 - INFO - Comics organization process finished.
2026-01-05 10:39:15,048 - INFO - Starting comics organization process...
2026-01-05 10:39:15,106 - INFO - Found 0 series to process.
2026-01-05 10:39:15,107 - INFO - Comics organization process finished.
2026-01-05 10:48:01,523 - INFO - Starting comics organization process...
2026-01-05 10:48:01,592 - INFO - Found 0 series to process.
2026-01-05 10:48:01,593 - INFO - Comics organization process finished.
2026-01-05 11:20:03,107 - INFO - Starting comics organization process...
2026-01-05 11:20:03,140 - INFO - Found 0 series to process.
2026-01-05 11:20:03,140 - INFO - Comics organization process finished.
2026-01-05 13:01:41,656 - INFO - Starting comics organization process...
2026-01-05 13:01:41,694 - INFO - Found 0 series to process.
2026-01-05 13:01:41,694 - INFO - Comics organization process finished.
2026-01-05 13:05:14,772 - INFO - Starting comics organization process...
2026-01-05 13:05:14,808 - INFO - Found 0 series to process.
2026-01-05 13:05:14,808 - INFO - Comics organization process finished.
2026-01-05 13:11:59,243 - INFO - Starting comics organization process...
2026-01-05 13:11:59,296 - INFO - Found 0 series to process.
2026-01-05 13:11:59,296 - INFO - Comics organization process finished.
2026-01-05 13:53:01,700 - INFO - Starting comics organization process...
2026-01-05 13:53:01,741 - INFO - Found 0 series to process.
2026-01-05 13:53:01,742 - INFO - Comics organization process finished.
2026-01-05 13:58:28,058 - INFO - Starting comics organization process...
2026-01-05 13:58:28,095 - INFO - Found 0 series to process.
2026-01-05 13:58:28,096 - INFO - Comics organization process finished.
2026-01-05 14:45:32,221 - INFO - Starting comics organization process...
2026-01-05 14:45:32,271 - INFO - Found 0 series to process.
2026-01-05 14:45:32,272 - INFO - Comics organization process finished.
2026-01-05 14:50:01,908 - INFO - Starting comics organization process...
2026-01-05 14:50:01,953 - INFO - Found 0 series to process.
2026-01-05 14:50:01,954 - INFO - Comics organization process finished.
@@ -0,0 +1 @@
/videos/castingcurvy/Videos
@@ -0,0 +1,88 @@
/videos/castingcurvy/Videos
/videos/castingcurvy/CastingCurvy 2022.11.19 - 2024.09.16 Siterip
/videos/castingcurvy/CastingCurvy 2023.02.09 - 2025.09.29 Megapack
/videos/castingcurvy/[OnlyFans] - @Castingcurvy
/videos/videodownloads1
/videos/melanie.hicks
/videos/tifa.quinn
/videos/renee.rose
/videos/josie.jaxxon
/videos/unsorted
/videos/katie.cummings
/videos/YoungFatties BBW Mini Pack
/videos/cassie.lenoir
/videos/nika.venom
/videos/jackie.hoff
/videos/blazedstudios
/videos/olivia.stark_aka_olivia.lapiedra
/videos/stephie.staar
/videos/riley.jean.aka.alex.jane
/videos/ophelia.kaan
/videos/jav
/videos/andi.rose
/videos/summer.renee
/videos/redmilf
/videos/penelope.kay
/videos/krystal.orchid
/videos/sofia.lee
/videos/camille.black
/videos/nikki.brooks
/videos/sailor.luna_aka_rebecca.vanguard
/videos/natasha.nice
/videos/olivia.kasady
/videos/lulu.chu
/videos/coco.lovelock
/videos/chloe.surreal
/videos/kate.england
/videos/tristan.summers
/videos/annabel.harvey
/videos/rylee.blake
/videos/andi.peacock
/videos/kat.the.kunt
/videos/serena.hill
/videos/shelby.paris
/videos/kelly.payne
/videos/khloe.kapri
/videos/savannahsolo
/videos/sarah_sota
/videos/sophia.sterling
/videos/dahlia.dee
/videos/red.eviee
/videos/kira.fox.aka.chloe.cooper
/videos/arabella.mae
/videos/nickey.huntsman
/videos/maisey.monroe
/videos/freya.von.doom
/videos/nali.marie
/videos/sunny.bunny
/videos/molly.little
/videos/rebecca.vanguard
/videos/coomerparty
/videos/rosedkush
/videos/blairebabie666
/videos/remy.woods
/videos/ophelia.purr
/videos/taboofantasy
/videos/raquel.roper
/videos/kimmy.kimm
/videos/annabel.redd
/videos/peachy.alice
/videos/cassie.tease
/videos/leia.lovelyn
/videos/cadence.lux
/videos/rissa.may
/videos/zoey.uso
/videos/dakota.burns
/videos/summer.dayze
/videos/anastasia.rose
/videos/scarlett.venom
/videos/adaline.star
/videos/ivy.plant gal
/videos/mallory.brookes
/videos/majikmilk
/videos/lilly.bell
/videos/nicoletta.queen
/videos/raeley.love
/videos/taylee.wood
/videos/__trash__
/videos/fifi.foxx
@@ -6,3 +6,7 @@ python-multipart
SQLAlchemy
python-dotenv
pydantic-settings
requests
pillow
beautifulsoup4
cloudscraper
@@ -0,0 +1,46 @@
# Virtual environment directories
myenv/
venv/
.venv/
# Python cache and compiled files
*.py[cod]
*.pyc
__pycache__/
# Python distribution artifacts
*.egg-info/
build/
dist/
Scripts/
# Test caches
.pytest_cache/
# IDE/editor configuration files
.idea/
.vscode/
# Operating system files
.DS_Store
Thumbs.db
# Sensitive configuration files
.env
# Other files to ignore
*.csv
*.log
coomer.py
descargas_test/
list.txt
resources/config/logs/*
setup.py
test/
test.py
test/parse.py
app/user_panel.py
resources/config/download_path/download_folder.json
CLI/
resources/config/downloads.db
resources/config/cookies/*
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Emy69
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,114 @@
![Windows Compatibility](https://img.shields.io/badge/Windows-10%2C%2011-blue)
![Downloads](https://img.shields.io/github/downloads/emy69/CoomerDL/total)
# Coomer Downloader App
**Coomer Downloader App** is a Python-based desktop application that simplifies downloading images and videos from various URLs. With an intuitive GUI, you can paste a link and let the app handle the rest.
---
## Support My Work
If you find this tool helpful, please consider supporting my efforts:
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00.svg?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/emy_69)
[![Support on Patreon](https://img.shields.io/badge/Support%20on%20Patreon-FF424D.svg?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/emy69)
---
## Features
### Download Images and Videos
- **Multithreaded Downloads**: Boosts download speed by utilizing multiple threads.
- **Progress Feedback**: Real-time progress updates during downloads.
- **Queue Management**: Efficiently handles large download queues.
**Supported File Extensions**:
- **Videos**: `.mp4`, `.mkv`, `.webm`, `.mov`, `.avi`, `.flv`, `.wmv`, `.m4v`
- **Images**: `.jpg`, `.jpeg`, `.png`, `.gif`, `.bmp`, `.tiff`
- **Documents**: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`
- **Compressed**: `.zip`, `.rar`, `.7z`, `.tar`, `.gz`
---
## Supported Pages
- [coomer.su](https://coomer.su/)
- [kemono.su](https://kemono.su/)
- [erome.com](https://www.erome.com/)
- [bunkr.albums.io](https://bunkr-albums.io/)
- [simpcity.su](https://simpcity.su/)
- [jpg5.su](https://jpg5.su/)
---
## CLI Tools
If you prefer using command-line interfaces, check out the following projects:
- **[Coomer CLI](https://github.com/Emy69/Coomer-cli)**
A CLI tool for downloading media from Coomer and similar sites. It offers customizable options for file naming, download modes, rate limiting, checksum verification, and more.
- **[Simpcity CLI](https://github.com/Emy69/SimpCityCLI)**
A CLI tool specifically designed for downloading media from Simpcity. It shares many features with Coomer CLI and is tailored for the Simpcity platform.
---
## Language Support
- [Español](#)
- [English](#)
- [日本語 (Japanese)](#)
- [中文 (Chinese)](#)
- [Français (French)](#)
- [Русский (Russian)](#)
---
## Community
Have questions or just want to say hi? Join the Discord server:
[![Join Discord](https://img.shields.io/badge/Join-Discord-7289DA.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ku8gSPsesh)
---
## Downloads
- **Latest Version**: Visit the [Releases Page](https://github.com/Emy69/CoomerDL/releases) to download the newest version.
---
## Usage
1. Launch the application.
2. Paste the URL of the image or video you want to download.
3. Click **Download** and wait for the process to finish.
![Usage GIF](https://github.com/Emy69/CoomerDL/blob/main/resources/screenshots/0627.gif)
---
## Clone the Repository
To get a local copy of the project, run the following command:
```sh
git clone https://github.com/Emy69/CoomerDL.git
```
### Install Dependencies
Navigate to the project folder:
```sh
cd CoomerDL
```
Then install the required dependencies:
```sh
pip install -r requirements.txt
```
### Run the Application
Once everything is installed, you can start the application with:
```sh
python main.py
```
@@ -0,0 +1,187 @@
import threading
import customtkinter as ctk
import webbrowser
import requests
from PIL import Image
class AboutWindow:
def __init__(self, parent, translate, version):
self.parent = parent
self.translate = translate
self.version = version
def get_github_data(self):
url = "https://api.github.com/repos/Emy69/CoomerDL"
try:
response = requests.get(url)
response.raise_for_status()
repo_data = response.json()
# Obtener la fecha de creación y las descargas totales (si existen releases)
created_at = repo_data.get("created_at", "N/A")
created_date = created_at.split("T")[0] if created_at != "N/A" else "N/A"
releases_url = repo_data.get("releases_url", "").replace("{/id}", "")
releases_response = requests.get(releases_url)
releases_response.raise_for_status()
releases_data = releases_response.json()
total_downloads = sum(
asset["download_count"] for release in releases_data for asset in release.get("assets", [])
) if releases_data else 0
return created_date, total_downloads
except Exception as e:
print(f"Error fetching GitHub data: {e}")
return "N/A", 0
def show_about(self):
# Crear una nueva ventana
about_window = ctk.CTkToplevel(self.parent)
about_window.title(self.translate("About"))
about_window.geometry("300x600")
about_window.resizable(False, False)
# Centrar la ventana
self.center_window(about_window, 300, 600)
# Hacer que la ventana aparezca al frente
about_window.transient(self.parent)
about_window.lift()
about_window.grab_set()
# placeholders mientras carga
created_date = self.translate("Loading...")
total_downloads = self.translate("Loading...")
# Crear un marco para el contenido
about_frame = ctk.CTkFrame(about_window, corner_radius=15)
about_frame.pack(fill="both", expand=True, padx=10, pady=10)
# Encabezado estilizado
header_label = ctk.CTkLabel(
about_frame,
text=self.translate("About This App"),
font=("Helvetica", 20, "bold"),
text_color="white",
anchor="w"
)
header_label.pack(pady=(10, 5), padx=10, anchor="w")
# Cargar las imágenes de los íconos
developer_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/user-account-solid-24.png"), size=(20, 20))
version_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/git-branch-line.png"), size=(20, 20))
downloads_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/download_icon.png"), size=(20, 20))
date_icon = ctk.CTkImage(Image.open("resources/img/iconos/about/calendar-event-line.png"), size=(20, 20))
# labels que se actualizarán
self.downloads_label = None
self.date_label = None
details = [
(developer_icon, f"{self.translate('Developer')}: Emy69", None),
(version_icon, f"{self.translate('Version')}: {self.version}", None),
(downloads_icon, f"{self.translate('Total Downloads')}: {total_downloads}", "downloads"),
(date_icon, f"{self.translate('Release Date')}: {created_date}", "date")
]
for icon, text, key in details:
detail_frame = ctk.CTkFrame(about_frame, fg_color="transparent")
detail_frame.pack(anchor="w", pady=5, padx=30)
icon_label = ctk.CTkLabel(detail_frame, text="", image=icon)
icon_label.pack(side="left", padx=(0, 10))
text_label = ctk.CTkLabel(
detail_frame,
text=text,
font=("Helvetica", 14),
text_color="white",
justify="left"
)
text_label.pack(side="left")
if key == "downloads": # agregado
self.downloads_label = text_label
elif key == "date": # agregado
self.date_label = text_label
separator = ctk.CTkFrame(about_frame, height=1, fg_color="gray")
separator.pack(fill="x", padx=10, pady=10)
# Sección de plataformas soportadas
supported_label = ctk.CTkLabel(
about_frame,
text=self.translate("Supported Platforms"),
font=("Helvetica", 16, "bold"),
text_color="white",
anchor="w"
)
supported_label.pack(pady=(10, 5), padx=10, anchor="w")
# Lista de plataformas con íconos
platforms = [
("coomer.su", "https://coomer.su"),
("kemono.su", "https://kemono.su"),
("erome.com", "https://erome.com"),
("bunkr-albums.io", "https://bunkr-albums.io"),
("simpcity.su", "https://simpcity.su"),
("jpg5.su", "https://jpg5.su")
]
for name, url in platforms:
# Crear el marco para cada plataforma
platform_frame = ctk.CTkFrame(about_frame, fg_color="transparent")
platform_frame.pack(anchor="w", pady=5, padx=10)
icon_image = ctk.CTkImage(Image.open("resources/img/iconos/about/global-line.png"), size=(20, 20))
icon_label = ctk.CTkLabel(platform_frame, text="", image=icon_image)
icon_label.pack(side="left")
# Botón con el nombre de la plataforma
platform_button = ctk.CTkButton(
platform_frame,
text=name,
font=("Helvetica", 14),
fg_color="transparent",
hover_color="gray25",
command=lambda u=url: webbrowser.open(u)
)
platform_button.pack(side="left")
# Footer
footer_label = ctk.CTkLabel(
about_frame,
text=self.translate("Thank you for using our app!"),
font=("Helvetica", 12, "italic"),
text_color="white",
anchor="w"
)
footer_label.pack(pady=(10, 10), padx=10, anchor="w")
# hilo para cargar datos
def fetch_and_update():
cd, td = self.get_github_data()
def safe_update():
try:
if not about_window.winfo_exists():
return
if self.date_label and self.date_label.winfo_exists():
self.date_label.configure(text=f"{self.translate('Release Date')}: {cd}")
if self.downloads_label and self.downloads_label.winfo_exists():
self.downloads_label.configure(text=f"{self.translate('Total Downloads')}: {td}")
except Exception:
pass
about_window.after(0, safe_update)
threading.Thread(target=fetch_and_update, daemon=True).start()
def center_window(self, window, width, height):
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = int((screen_width / 2) - (width / 2))
y = int((screen_height / 2) - (height / 2))
window.geometry(f'{width}x{height}+{x}+{y}')
@@ -0,0 +1,221 @@
import os
import customtkinter as ctk
import requests
import threading
import json
import tkinter as tk
from PIL import Image
class DonorsModal(ctk.CTkToplevel):
def __init__(self, parent, tr):
super().__init__(parent)
self.parent = parent
self.tr = tr # Translation function
self.title(self.tr("Patreons"))
self.geometry("600x600")
self.resizable(False, False)
self.transient(parent)
# Centrar la ventana y crear la UI
self.center_window()
self.create_ui()
self.after_idle(self._safe_grab)
# Start loading data in a separate thread to prevent UI freeze
threading.Thread(target=self._load_donors, daemon=True).start()
def _safe_grab(self):
"""Intenta grab_set hasta que la ventana sea visible."""
try:
self.grab_set()
except tk.TclError:
# Aún no es “viewable”; reintenta en 20 ms
self.after(20, self._safe_grab)
def center_window(self):
"""Centers the modal window relative to the parent window."""
self.update_idletasks()
parent_x = self.parent.winfo_x()
parent_y = self.parent.winfo_y()
parent_w = self.parent.winfo_width()
parent_h = self.parent.winfo_height()
window_w = self.winfo_width()
window_h = self.winfo_height()
cx = parent_x + (parent_w // 2) - (window_w // 2)
cy = parent_y + (parent_h // 2) - (window_h // 2)
self.geometry(f"{window_w}x{window_h}+{cx}+{cy}")
def create_ui(self):
"""Creates the UI elements for the Top Donors modal."""
main_frame = ctk.CTkFrame(self)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
title_label = ctk.CTkLabel(
main_frame,
text=self.tr("Patreons"),
font=ctk.CTkFont(size=24, weight="bold")
)
title_label.pack(pady=(0, 20))
self.donors_frame = ctk.CTkScrollableFrame(main_frame, fg_color="transparent")
self.donors_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self.grid_container = ctk.CTkFrame(self.donors_frame, fg_color="transparent")
self.grid_container.pack(fill="both", expand=True)
self.status_label = ctk.CTkLabel(
self.grid_container,
text=self.tr("Loading Patreons..."),
font=ctk.CTkFont(size=14),
text_color="gray"
)
self.status_label.grid(pady=20)
close_button = ctk.CTkButton(
main_frame,
text=self.tr("Close"),
command=self.destroy
)
close_button.pack(pady=(10, 0))
def _load_donors(self):
"""Fetches donor data from the API."""
donors = []
error_message = ""
try:
# Explicitly set Accept and User-Agent headers
resp = requests.get(
'https://emydevs.com/coomer/donadores.php',
headers={
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36'
},
timeout=10
)
resp.raise_for_status()
donors = resp.json()
except requests.exceptions.RequestException as req_e:
error_message = str(req_e)
print(f"Error fetching donors: {error_message}")
self.after(0, lambda msg=error_message: self.status_label.configure(text=self.tr("Error fetching donors: {error}").format(error=msg), text_color="red"))
return
except json.JSONDecodeError as json_e:
error_message = str(json_e)
print(f"Error decoding JSON: {error_message}")
self.after(0, lambda msg=error_message: self.status_label.configure(text=self.tr("Error processing donor data: {error}").format(error=msg), text_color="red"))
return
# Update the UI on the main thread
self.after(0, lambda: self._show_donors(donors))
def _show_donors(self, donors):
"""Displays donor data in the modal."""
# Clear status label
self.status_label.grid_forget()
# Clear existing data
for widget in self.grid_container.winfo_children():
if widget is self.status_label:
continue
widget.destroy()
columns = 2
for c in range(columns):
self.grid_container.grid_columnconfigure(c, weight=1)
if not donors:
empty = ctk.CTkLabel(self.grid_container, text=self.tr("No donors found."), text_color="gray")
empty.grid(row=0, column=0, columnspan=columns, pady=20)
return
# Sort by amount in descending order, ensuring numeric comparison
def _to_float(v):
try:
return float(v)
except Exception:
return 0.0
donors.sort(key=lambda x: _to_float(x.get("donated_amount", 0)), reverse=True)
# Cache icons once (PNG medals and default)
if not hasattr(self, "_donor_icons"):
icon_dir = os.path.join("resources", "img", "iconos", "donors")
def _load_icon(fname):
path = os.path.join(icon_dir, fname)
if os.path.exists(path):
return ctk.CTkImage(
light_image=Image.open(path),
dark_image=Image.open(path),
size=(20, 20)
)
return None
# Keep a dict for quick access and to hold references
self._donor_icons = {
"gold": _load_icon("gold.png"),
"silver": _load_icon("silver.png"),
"bronze": _load_icon("bronze.png"),
"default": _load_icon("default.png"),
}
# Info note about donors data update
info_label = ctk.CTkLabel(
self.grid_container,
text=self.tr(
"Note: Donor information is updated every 10th of each month.\n"
"Names and donation amounts are retrieved from my Patreon page."
),
font=ctk.CTkFont(size=12, slant="italic"),
text_color="gray",
justify="center",
wraplength=350 # wrap text to fit in ~350px width
)
info_label.grid(row=0, column=0, columnspan=columns, pady=(0, 10), sticky="ew")
for i, donor in enumerate(donors):
donor_name = donor.get("name", self.tr("Unknown Donor"))
# Select icon and colors
icon_key = "default"
font_size = 14
font_weight = "normal"
name_color = "#E0E0E0"
icon_img = self._donor_icons.get(icon_key)
# Row container
donor_row_frame = ctk.CTkFrame(self.grid_container, fg_color="transparent", corner_radius=8)
donor_row_frame.grid_columnconfigure(0, weight=0)
donor_row_frame.grid_columnconfigure(1, weight=1)
row = (i // columns) + 1
col = i % columns
donor_row_frame.grid(row=row, column=col, padx=8, pady=8, sticky="nsew")
# Rank label with icon
rank_label = ctk.CTkLabel(
donor_row_frame,
text="", # remove numeric order
image=icon_img,
compound="left",
font=ctk.CTkFont(size=font_size, weight=font_weight),
width=40,
anchor="w",
text_color=name_color
)
rank_label.grid(row=0, column=0, padx=(5, 5), pady=5, sticky="w")
# Name label
name_label = ctk.CTkLabel(
donor_row_frame,
text=donor_name,
font=ctk.CTkFont(size=font_size, weight=font_weight),
anchor="w",
text_color=name_color
)
name_label.grid(row=0, column=1, padx=(0, 10), pady=5, sticky="ew")
def update_donor_data(self, new_donors):
self._show_donors(new_donors) # Call _show_donors with new data
@@ -0,0 +1,128 @@
import tkinter as tk
import requests
from tkinterweb import HtmlFrame
import customtkinter as ctk
import markdown2
class PatchNotes:
WINDOW_WIDTH = 900
WINDOW_HEIGHT = 800
def __init__(self, parent, translations_func):
self.parent = parent
self.tr = translations_func
self.patch_notes_window = None
def show_patch_notes(self):
# Cerrar la ventana si ya existe
if self.patch_notes_window is not None and tk.Toplevel.winfo_exists(self.patch_notes_window):
self.patch_notes_window.destroy()
self.patch_notes_window = ctk.CTkToplevel(self.parent)
self.patch_notes_window.title(self.tr("Patch Notes"))
self.patch_notes_window.transient(self.parent)
# Centrar la ventana
self.center_window(self.patch_notes_window, self.WINDOW_WIDTH, self.WINDOW_HEIGHT)
# Forzar visibilidad y foco
self.patch_notes_window.deiconify()
self.patch_notes_window.focus_force()
main_frame = ctk.CTkFrame(self.patch_notes_window)
main_frame.pack(expand=True, fill="both", padx=20, pady=20)
# Obtener el release más reciente desde GitHub
latest_release = self.get_latest_github_release('Emy69', 'CoomerDL')
# Crear un HtmlFrame para mostrar el contenido HTML
html_frame = HtmlFrame(main_frame, messages_enabled=False)
html_frame.pack(fill="both", expand=True, padx=10, pady=10)
# Cargar el contenido HTML
if latest_release:
patch_notes_html = self.get_patch_notes_html(latest_release)
html_frame.load_html(patch_notes_html)
else:
html_frame.load_html("<p>Failed to load patch notes.</p>")
# Añadir un frame inferior para el botón y opción de "No mostrar de nuevo"
bottom_frame = ctk.CTkFrame(self.patch_notes_window)
bottom_frame.pack(fill="x", padx=20, pady=(0, 20))
dont_show_again_var = tk.IntVar()
dont_show_again_check = ctk.CTkCheckBox(bottom_frame, text=self.tr("Don't show again"), variable=dont_show_again_var)
dont_show_again_check.pack(side="left", padx=5)
ok_button = ctk.CTkButton(bottom_frame, text=self.tr("OK"), command=lambda: self.close_patch_notes(dont_show_again_var))
ok_button.pack(side="right", padx=5)
@staticmethod
def get_latest_github_release(repo_owner, repo_name):
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
response = requests.get(url)
if response.status_code == 200:
return response.json()
else:
return None
def center_window(self, window, width, height):
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = int((screen_width / 2) - (width / 2))
y = int((screen_height / 2) - (height / 2))
window.geometry(f'{width}x{height}+{x}+{y}')
def get_patch_notes_html(self, latest_release):
# Convertir el cuerpo del release de Markdown a HTML
release_body_html = markdown2.markdown(latest_release['body'])
html_content = f"""
<html>
<head>
<style>
body {{
font-family: 'Arial', sans-serif;
background-color: #1e1e1e;
color: #f5f5f5;
padding: 20px;
margin: 0;
}}
h1 {{
color: #ffcc00;
text-align: center;
margin-top: 10px;
}}
h2 {{
color: #ff6600;
margin-bottom: 5px;
text-align: center;
}}
p {{
margin-bottom: 15px;
line-height: 1.6;
text-align: justify;
}}
.footer {{
text-align: center;
font-size: 12px;
color: #888;
margin-top: 20px;
}}
</style>
</head>
<body>
<h1>{self.tr('Patch Notes')}</h1>
<h2>{latest_release['name']}</h2>
{release_body_html}
</body>
</html>
"""
return html_content
def close_patch_notes(self, dont_show_again_var):
if self.patch_notes_window is not None:
self.patch_notes_window.destroy()
self.patch_notes_window = None
@@ -0,0 +1,192 @@
import customtkinter as ctk
import os
class ProgressManager:
def __init__(self, root, icons, footer_speed_label, footer_eta_label, progress_bar, progress_percentage):
self.root = root
self.icons = icons
self.footer_speed_label = footer_speed_label
self.footer_eta_label = footer_eta_label
self.progress_bar = progress_bar
self.progress_percentage = progress_percentage
self.progress_bars = {}
self.progress_window = None
self.no_downloads_label = None
self.progress_details_frame = None
self.auto_show_details = False
def create_progress_window(self):
if self.progress_window is None or not self.progress_window.winfo_exists():
self.progress_window = ctk.CTkToplevel(self.root)
self.progress_window.title("Detalles de Descarga")
self.progress_window.geometry("600x500")
self.progress_window.resizable(True, True)
self.progress_window.protocol("WM_DELETE_WINDOW", self.close_progress_window)
self.progress_details_frame = ctk.CTkFrame(self.progress_window)
self.progress_details_frame.pack(fill='both', expand=True, padx=10, pady=10)
# Crear el mensaje de "No hay descargas"
self.no_downloads_label = ctk.CTkLabel(self.progress_details_frame, text="No hay descargas en cola", font=("Arial", 14))
self.no_downloads_label.pack(pady=20)
# Hacer que la ventana aparezca al frente
self.progress_window.transient(self.root)
self.progress_window.grab_set()
def close_progress_window(self):
if self.progress_window is not None and self.progress_window.winfo_exists():
self.progress_window.grab_release()
self.progress_window.withdraw()
def update_progress(self, downloaded, total, file_id=None, file_path=None, speed=None, eta=None, status: str | None = None):
if status is not None:
original = self.footer_eta_label.cget("text")
base_eta = original.split(" | ")[0]
self.footer_eta_label.configure(text=f"{base_eta} | STATUS:{status}")
# Oculta el estado tras 5 s
self.footer_eta_label.after(
5000,
lambda: self.footer_eta_label.configure(text=base_eta)
)
return
if total > 0:
percentage = (downloaded / total) * 100
remaining = total - downloaded
if file_id is None:
if self.progress_bar.winfo_exists():
self.progress_bar.set(downloaded / total)
self.progress_percentage.configure(text=f"{percentage:.2f}%")
else:
if self.progress_window is None or not self.progress_window.winfo_exists():
return
if file_id not in self.progress_bars:
if self.no_downloads_label and self.no_downloads_label.winfo_exists():
self.no_downloads_label.pack_forget()
file_name = os.path.basename(file_path)
file_extension = os.path.splitext(file_path)[1].lower()
# Determinar el icono
if file_extension in ['.jpg', '.jpeg', '.png', '.gif']:
icon = self.icons['image']
elif file_extension in ['.mp4', '.avi', '.mkv']:
icon = self.icons['video']
elif file_extension in ['.zip', '.rar']:
icon = self.icons['zip']
else:
icon = self.icons['default']
progress_bar_frame = ctk.CTkFrame(self.progress_details_frame)
progress_bar_frame.pack(fill='x', padx=5, pady=5)
# Crear un contenedor para el icono y el texto
icon_and_text_frame = ctk.CTkFrame(progress_bar_frame)
icon_and_text_frame.pack(side='left', padx=5)
# Crear el icono con ctk.CTkLabel
icon_label = ctk.CTkLabel(icon_and_text_frame, image=icon, text="")
icon_label.pack(side='left')
# Limitar el texto y mostrar puntos suspensivos si excede el límite
max_text_length = 30
if len(file_name) > max_text_length:
displayed_file_name = file_name[:max_text_length] + '...'
else:
displayed_file_name = file_name
progress_label = ctk.CTkLabel(icon_and_text_frame, text=displayed_file_name, anchor='w')
progress_label.pack(side='left', padx=5)
# Crear barra de progreso y etiquetas de porcentaje y ETA
progress_bar = ctk.CTkProgressBar(progress_bar_frame)
progress_bar.pack(fill='x', padx=5, pady=5)
# Mostrar porcentaje y megabytes restantes
percentage_label = ctk.CTkLabel(progress_bar_frame, text=f"{percentage:.2f}% ({downloaded / 1048576:.2f} MB / {total / 1048576:.2f} MB)")
percentage_label.pack(side='left', padx=5)
eta_label = ctk.CTkLabel(progress_bar_frame, text=f"ETA: N/A")
eta_label.pack(side='right', padx=5)
self.progress_bars[file_id] = (progress_bar, percentage_label, eta_label, progress_bar_frame)
if file_id in self.progress_bars and self.progress_bars[file_id][0].winfo_exists():
self.progress_bars[file_id][0].set(downloaded / total)
self.progress_bars[file_id][1].configure(text=f"{percentage:.2f}% ({downloaded / 1048576:.2f} MB / {total / 1048576:.2f} MB)")
if eta is not None:
eta_text = f"ETA: {int(eta // 60)}m {int(eta % 60)}s"
self.progress_bars[file_id][2].configure(text=eta_text)
# Eliminar el frame si la descarga está completa
if downloaded >= total:
self.remove_progress_bar(file_id)
else:
if file_id is None:
if self.progress_bar.winfo_exists():
self.progress_bar.set(0)
self.progress_percentage.configure(text="0%")
else:
if file_id in self.progress_bars and self.progress_bars[file_id][0].winfo_exists():
self.progress_bars[file_id][0].set(0)
self.progress_bars[file_id][1].configure(text="0%")
self.progress_bars[file_id][2].configure(text="ETA: N/A")
# Actualizar velocidad en el footer (si corresponde)
if speed is not None:
if speed < 1_048_576:
speed_text = f"Speed: {speed / 1024:.2f} KB/s"
else:
speed_text = f"Speed: {speed / 1_048_576:.2f} MB/s"
self.footer_speed_label.configure(text=speed_text)
if eta is not None:
mins, secs = divmod(int(eta), 60)
self.footer_eta_label.configure(text=f"ETA: {mins}m {secs}s")
def remove_progress_bar(self, file_id):
if file_id in self.progress_bars and self.progress_bars[file_id][3].winfo_exists():
# Programar la eliminación del frame después de 2 segundos
self.progress_bars[file_id][3].after(2000, lambda: self._forget_and_delete(file_id))
def _forget_and_delete(self, file_id):
if file_id in self.progress_bars and self.progress_bars[file_id][3].winfo_exists():
self.progress_bars[file_id][3].pack_forget()
del self.progress_bars[file_id]
# Si ya no quedan descargas, mostrar mensaje y restablecer el footer
if not self.progress_bars:
self.no_downloads_label.pack(pady=20)
# Restablecer valores por defecto en el footer
self.footer_speed_label.configure(text="Speed: 0 KB/s")
self.footer_eta_label.configure(text="ETA: N/A")
def update_global_progress(self, completed_files, total_files):
if total_files > 0:
percentage = (completed_files / total_files) * 100
if self.progress_bar.winfo_exists():
self.progress_bar.set(completed_files / total_files)
self.progress_percentage.configure(text=f"{percentage:.2f}%")
def toggle_progress_details(self):
self.create_progress_window()
if self.progress_window.winfo_viewable():
self.close_progress_window()
else:
self.center_progress_details_frame()
self.progress_window.deiconify()
self.progress_window.lift()
def center_progress_details_frame(self):
if self.progress_window is not None and self.progress_window.winfo_exists():
self.progress_window.update_idletasks()
width = self.progress_window.winfo_width()
height = self.progress_window.winfo_height()
x = (self.root.winfo_screenwidth() // 2) - (width // 2)
y = (self.root.winfo_screenheight() // 2) - (height // 2)
self.progress_window.geometry(f"{width}x{height}+{x}+{y}")
self.progress_window.minsize(width, height)
@@ -0,0 +1,905 @@
import json
import os
import sqlite3
import threading
from tkinter import filedialog, messagebox, ttk
import customtkinter as ctk
import tkinter as tk
from PIL import Image, ImageTk
from PIL import Image as PilImage
import requests
class SettingsWindow:
CONFIG_PATH = 'resources/config/settings.json'
def __init__(self, parent, translate, load_translations_func, update_ui_texts_func, save_language_preference_func, version, downloader, check_for_new_version_func,on_settings_changed=None):
self.parent = parent
self.translate = translate
self.load_translations = load_translations_func
self.update_ui_texts = update_ui_texts_func
self.save_language_preference = save_language_preference_func
self.version = version
self.downloader = downloader
self.check_for_new_version = check_for_new_version_func
self.languages = {
"Español": "es",
"English": "en",
"日本語": "ja",
"中文": "zh",
"Français": "fr",
"Русский": "ru"
}
self.settings = self.load_settings()
self.folder_structure_icons = self.load_icons()
self.on_settings_changed = on_settings_changed
self.site_status_labels = {}
self.site_textboxes = {}
def load_settings(self):
if not os.path.exists(self.CONFIG_PATH):
return {'max_downloads': 3, 'folder_structure': 'default', 'language': 'en', 'theme': 'System'}
try:
with open(self.CONFIG_PATH, 'r') as file:
return json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
return {'max_downloads': 3, 'folder_structure': 'default', 'language': 'en', 'theme': 'System'}
def save_settings(self):
os.makedirs(os.path.dirname(self.CONFIG_PATH), exist_ok=True)
with open(self.CONFIG_PATH, 'w') as file:
json.dump(self.settings, file, indent=4)
if callable(self.on_settings_changed):
self.on_settings_changed(self.settings)
def load_icons(self):
icons = {}
try:
icons['folder'] = ImageTk.PhotoImage(PilImage.open("resources/img/iconos/settings/folder.png").resize((20, 20), PilImage.Resampling.LANCZOS))
except Exception as e:
messagebox.showerror(self.translate("Error"), self.translate(f"Error loading icons: {e}"))
icons['folder'] = None
return icons
def open_settings(self):
self.settings_window = ctk.CTkToplevel(self.parent)
self.settings_window.title(self.translate("Settings"))
self.settings_window.geometry("800x600")
self.settings_window.transient(self.parent)
self.settings_window.deiconify()
self.settings_window.after_idle(self.settings_window.grab_set)
self.center_window(self.settings_window, 800, 600)
self.main_frame = ctk.CTkFrame(self.settings_window)
self.main_frame.pack(fill="both", expand=True, padx=10, pady=10)
self.tabview = ctk.CTkTabview(self.main_frame, width=700, height=500)
self.tabview.pack(fill="both", expand=True)
# Existing tabs
self.general_tab = self.tabview.add(self.translate("General"))
self.downloads_tab = self.tabview.add(self.translate("Downloads"))
self.structure_tab = self.tabview.add(self.translate("Structure"))
self.db_tab = self.tabview.add(self.translate("Database"))
self.cookies_tab = self.tabview.add(self.translate("Cookies"))
# New tab for database
self.render_general_tab(self.general_tab)
self.render_downloads_tab(self.downloads_tab)
self.render_structure_tab(self.structure_tab)
self.render_db_tab(self.db_tab)
self.render_cookies_tab(self.cookies_tab)
def render_db_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(0, weight=1)
db_frame = ctk.CTkFrame(tab, fg_color="transparent")
db_frame.pack(fill="both", expand=True, padx=20, pady=20)
header_label = ctk.CTkLabel(db_frame, text=self.translate("Database Management"), font=("Helvetica", 16, "bold"))
header_label.pack(pady=(0, 10))
# Configurar un estilo personalizado para el Treeview
style = ttk.Style()
style.theme_use('clam') # O puedes elegir 'default' o 'alt'
style.configure("Custom.Treeview",
background="#2e2e2e",
foreground="white",
fieldbackground="#2e2e2e",
rowheight=25,
font=('Helvetica', 10))
style.configure("Custom.Treeview.Heading",
background="#1e1e1e",
foreground="white",
font=('Helvetica', 10, 'bold'))
# Para filas alternas (opcional)
style.map("Custom.Treeview", background=[("selected", "#4a6984")])
# Frame para el Treeview y su scrollbar
table_frame = tk.Frame(db_frame)
table_frame.pack(fill="both", expand=True)
# Definir las columnas que se mostrarán en la parte derecha del Treeview
columns = ("id", "file_name", "type", "size", "downloaded_at")
self.db_tree = ttk.Treeview(table_frame, style="Custom.Treeview", columns=columns, show="tree headings", height=15, selectmode="extended")
# Configurar encabezados
self.db_tree.heading("#0", text=self.translate("User/Post"), anchor="w")
self.db_tree.heading("id", text="ID", anchor="center")
self.db_tree.heading("file_name", text=self.translate("File Name"), anchor="w")
self.db_tree.heading("type", text=self.translate("Type"), anchor="center")
self.db_tree.heading("size", text=self.translate("Size"), anchor="center")
self.db_tree.heading("downloaded_at", text=self.translate("Downloaded At"), anchor="center")
# Configurar ancho de columnas
self.db_tree.column("#0", width=180)
self.db_tree.column("id", width=30, anchor="center")
self.db_tree.column("file_name", width=200)
self.db_tree.column("type", width=80, anchor="center")
self.db_tree.column("size", width=80, anchor="center")
self.db_tree.column("downloaded_at", width=150, anchor="center")
# Scrollbar vertical
vsb = ttk.Scrollbar(table_frame, orient="vertical", command=self.db_tree.yview)
self.db_tree.configure(yscrollcommand=vsb.set)
self.db_tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
table_frame.grid_rowconfigure(0, weight=1)
table_frame.grid_columnconfigure(0, weight=1)
# Botones para exportar y limpiar
btn_frame = ctk.CTkFrame(db_frame, fg_color="transparent")
btn_frame.pack(pady=10)
export_button = ctk.CTkButton(btn_frame, text=self.translate("Export Database"), command=self.export_db)
export_button.pack(side="left", padx=10)
clear_button = ctk.CTkButton(btn_frame, text=self.translate("Clear Database"), command=self.clear_db)
clear_button.pack(side="left", padx=10)
delete_users_button = ctk.CTkButton(
btn_frame,
text=self.translate("Delete Selected Users"),
command=self.delete_selected_users
)
delete_users_button.pack(side="left", padx=10)
# Cargar los registros en el Treeview con agrupación por usuario y post
self.load_db_records()
def render_cookies_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(1, weight=1)
# Header
header_frame = ctk.CTkFrame(tab, fg_color="transparent")
header_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=(20, 10))
title_label = ctk.CTkLabel(
header_frame,
text="Cookie Management",
font=("Helvetica", 18, "bold")
)
title_label.pack(anchor="w")
desc_label = ctk.CTkLabel(
header_frame,
text="Paste or import cookies in JSON format (list of objects with name, value, domain, path). "
"They will be stored by site in resources/config/cookies/{sitio}.json",
font=("Helvetica", 12),
text_color="gray",
wraplength=800,
justify="left"
)
desc_label.pack(anchor="w", pady=(5, 0))
main_frame = ctk.CTkScrollableFrame(tab)
main_frame.grid(row=1, column=0, sticky="nsew", padx=20, pady=(0, 20))
main_frame.grid_columnconfigure(0, weight=1)
sites_config = [
{"key": "simpcity", "name": "SimpCity", "color": "#FF6B6B", "icon": "🌐",
"description": "Cookies for accessing SimCity"},
]
def cookie_path_for(site_key: str) -> str:
base = "resources/config/cookies"
os.makedirs(base, exist_ok=True)
return os.path.join(base, f"{site_key}.json")
for i, cfg in enumerate(sites_config):
site_key = cfg["key"]
site_name = cfg["name"]
site_color = cfg["color"]
site_icon = cfg["icon"]
site_desc = cfg["description"]
# --- Container ---
site_container = ctk.CTkFrame(main_frame)
site_container.grid(row=i, column=0, sticky="ew", pady=(0, 22), padx=10)
site_container.grid_columnconfigure(0, weight=1)
accent = ctk.CTkFrame(site_container, height=4, fg_color=site_color)
accent.grid(row=0, column=0, sticky="ew")
# Header
header = ctk.CTkFrame(site_container, fg_color="transparent")
header.grid(row=1, column=0, sticky="ew", padx=15, pady=(10, 6))
header.grid_columnconfigure(1, weight=1)
title = ctk.CTkLabel(header, text=f"{site_icon} {site_name}", font=("Helvetica", 16, "bold"))
title.grid(row=0, column=0, sticky="w")
status_exists = os.path.exists(cookie_path_for(site_key))
status_label = ctk.CTkLabel(
header,
text=("✓ Configuradas" if status_exists else "⚠ Sin configurar"),
font=("Helvetica", 11),
text_color=("#4CAF50" if status_exists else "#FF9800")
)
status_label.grid(row=0, column=1, sticky="e")
self.site_status_labels[site_key] = status_label
if site_desc:
desc = ctk.CTkLabel(
header, text=site_desc, font=("Helvetica", 11),
text_color="gray", wraplength=600, justify="left"
)
desc.grid(row=1, column=0, columnspan=2, sticky="w", pady=(4, 0))
instructions_frame = ctk.CTkFrame(site_container, fg_color=("gray90", "gray20"))
instructions_frame.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 8))
instructions = (
"📋 How to obtain cookies (JSON format):\n"
"1) Open the browser Developer Tools (F12).\n"
"2) Go to Application/Storage → Cookies and select the site.\n"
"3) Use an extension to export cookies as JSON (e.g., EditThisCookie), or copy the fields manually.\n"
"4) Paste the JSON below or import a .json file.\n"
" Expected format: an array of objects with at least name, value, domain, path."
)
ctk.CTkLabel(instructions_frame, text=instructions, font=("Helvetica", 10),
justify="left", anchor="w").pack(padx=12, pady=8, fill="x")
# Textbox
text_frame = ctk.CTkFrame(site_container, fg_color="transparent")
text_frame.grid(row=3, column=0, sticky="ew", padx=15, pady=(2, 6))
text_frame.grid_columnconfigure(0, weight=1)
textbox = ctk.CTkTextbox(text_frame, height=160, wrap="none")
textbox.grid(row=0, column=0, sticky="ew")
pre_path = cookie_path_for(site_key)
if os.path.exists(pre_path):
try:
with open(pre_path, "r", encoding="utf-8") as f:
textbox.delete("1.0", "end")
textbox.insert("1.0", f.read())
except Exception:
pass
self.site_textboxes[site_key] = textbox
buttons = ctk.CTkFrame(site_container, fg_color="transparent")
buttons.grid(row=4, column=0, sticky="ew", padx=15, pady=(4, 12))
buttons.grid_columnconfigure((0,1,2,3), weight=0)
buttons.grid_columnconfigure(4, weight=1)
def do_import(sk=site_key, tb=textbox):
file = filedialog.askopenfilename(
title=f"Import cookies JSON for {sk}",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
)
if not file:
return
try:
with open(file, "r", encoding="utf-8") as f:
content = f.read()
json.loads(content) # valida
tb.delete("1.0", "end")
tb.insert("1.0", content)
messagebox.showinfo("OK", f"Cookies loaded into editor for {sk}. Don't forget to Save!")
except Exception as e:
messagebox.showerror("Error", f"Invalid JSON: {e}")
def do_save(sk=site_key, tb=textbox, sl=status_label):
content = tb.get("1.0", "end").strip()
if not content:
messagebox.showwarning("Empty", "Nothing to save.")
return
try:
data = json.loads(content)
if isinstance(data, dict):
data = [data]
if not isinstance(data, list):
raise ValueError("JSON must be an object or an array of objects.")
for idx, c in enumerate(data, start=1):
if not isinstance(c, dict) or "name" not in c or "value" not in c:
raise ValueError(f"Item {idx} is missing 'name' or 'value'.")
save_path = cookie_path_for(sk)
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
sl.configure(text="✓ Configured", text_color="#4CAF50")
messagebox.showinfo("OK", f"Cookies saved to:\n{save_path}")
except Exception as e:
messagebox.showerror("Error", f"Could not save: {e}")
def do_test(sk=site_key, sl=status_label):
urls = {
"simpcity": ["https://www.simpcity.cr/"]
}
test_path = cookie_path_for(sk)
if not os.path.exists(test_path):
messagebox.showwarning("Missing file", "You haven't saved cookies for this site yet.")
return
try:
with open(test_path, "r", encoding="utf-8") as f:
cookies = json.load(f)
s = requests.Session()
jar = requests.cookies.RequestsCookieJar()
for c in cookies if isinstance(cookies, list) else [cookies]:
jar.set(c["name"], c["value"], domain=c.get("domain", None), path=c.get("path", "/"))
s.cookies = jar
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
}
ok_any, codes = False, []
for test_url in urls.get(sk, []):
try:
r = s.get(test_url, headers=headers, timeout=12)
codes.append(r.status_code)
if 200 <= r.status_code < 400:
ok_any = True
except Exception:
codes.append("ERR")
if ok_any:
sl.configure(text="✓ Configured (test OK)", text_color="#4CAF50")
messagebox.showinfo("OK", f"Response from {sk}: {codes}")
else:
sl.configure(text="⚠ Tested (non-OK response)", text_color="#FFC107")
messagebox.showwarning("Heads up", f"Response from {sk}: {codes}\n"
"Cookie format looks fine but the site returned non-OK status codes.\n"
"If content requires login, try a protected URL inside the downloader.")
except Exception as e:
sl.configure(text="⚠ Not configured", text_color="#FF9800")
messagebox.showerror("Error", f"Test failed: {e}")
def do_clear(sk=site_key, tb=textbox, sl=status_label):
del_path = cookie_path_for(sk)
if os.path.exists(del_path):
try:
os.remove(del_path)
except Exception as e:
messagebox.showerror("Error", f"Could not delete file: {e}")
return
tb.delete("1.0", "end")
sl.configure(text="⚠ Not configured", text_color="#FF9800")
messagebox.showinfo("OK", f"Cookies for {sk} deleted.")
ctk.CTkButton(buttons, text="Import JSON...", command=do_import).grid(row=0, column=0, padx=(0, 6))
ctk.CTkButton(buttons, text="Save",command=do_save).grid( row=0, column=1, padx=6)
ctk.CTkButton(buttons, text="Test",command=do_test).grid( row=0, column=2, padx=6)
ctk.CTkButton(buttons, text="Delete",command=do_clear, fg_color="#E53935", hover_color="#C62828").grid(row=0, column=3, padx=6)
def delete_selected_users(self):
selected = self.db_tree.selection()
if not selected:
messagebox.showwarning(
self.translate("Warning"),
self.translate("Please select at least one user to delete.")
)
return
user_ids = [
self.db_tree.item(node, 'text')
for node in selected
if self.db_tree.parent(node) == ''
]
if not user_ids:
messagebox.showwarning(
self.translate("Warning"),
self.translate("Please select valid user entries (not posts/files).")
)
return
confirm = messagebox.askyesno(
self.translate("Confirm"),
self.translate(
"Are you sure you want to delete all records for user(s): {}?".format(
", ".join(user_ids)
)
)
)
if not confirm:
return
try:
conn = sqlite3.connect(self.downloader.db_path)
cursor = conn.cursor()
for uid in user_ids:
cursor.execute("DELETE FROM downloads WHERE user_id = ?", (uid,))
conn.commit()
conn.close()
messagebox.showinfo(
self.translate("Success"),
self.translate("Selected user(s) and their records were deleted.")
)
self.load_db_records()
except Exception as e:
messagebox.showerror(
self.translate("Error"),
self.translate(f"Error deleting user(s): {e}")
)
def load_db_records(self):
"""Consulta la base de datos y carga los registros en el Treeview agrupados por usuario y, opcionalmente, por post."""
db_path = self.downloader.db_path
if not os.path.exists(db_path):
tk.messagebox.showwarning(self.translate("Warning"), self.translate("Database not found."))
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT id, media_url, file_path, file_size, user_id, post_id, downloaded_at FROM downloads")
rows = cursor.fetchall()
conn.close()
except Exception as e:
tk.messagebox.showerror(self.translate("Error"), self.translate("Error loading database: {e}", e=e))
return
# Limpiar el Treeview
for child in self.db_tree.get_children():
self.db_tree.delete(child)
# Función auxiliar para determinar el tipo de archivo según la extensión
def get_file_type(file_path):
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff']:
return self.translate("Image")
elif ext in ['.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.wmv', '.m4v']:
return self.translate("Video")
elif ext in ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']:
return self.translate("Document")
elif ext in ['.zip', '.rar', '.7z', '.tar', '.gz']:
return self.translate("Compressed")
else:
return self.translate("Other")
# Función para formatear el tamaño
def format_size(size):
if size < 1024:
return f"{size} B"
elif size < 1024**2:
return f"{size/1024:.2f} KB"
else:
return f"{size/1024**2:.2f} MB"
# Agrupar registros por usuario
usuarios = {}
for rec in rows:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
usuarios.setdefault(user_id, []).append(rec)
# Insertar cada usuario como nodo padre en el Treeview
for user, registros in usuarios.items():
user_node = self.db_tree.insert("", "end", text=user, open=False)
# Dentro de cada usuario, agrupar los registros que tienen post_id y los que no
posts = {}
sin_post = []
for rec in registros:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
if post_id:
posts.setdefault(post_id, []).append(rec)
else:
sin_post.append(rec)
# Insertar nodos para cada post
for post, recs in posts.items():
post_node = self.db_tree.insert(user_node, "end", text=post, open=False)
for rec in recs:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
file_name = os.path.basename(file_path)
file_type = get_file_type(file_path)
size_str = format_size(file_size)
self.db_tree.insert(post_node, "end", values=(rec_id, file_name, file_type, size_str, downloaded_at))
# Insertar los registros sin post en un nodo especial
if sin_post:
sin_post_node = self.db_tree.insert(user_node, "end", text=self.translate("No Post"), open=False)
for rec in sin_post:
rec_id, media_url, file_path, file_size, user_id, post_id, downloaded_at = rec
file_name = os.path.basename(file_path)
file_type = get_file_type(file_path)
size_str = format_size(file_size)
self.db_tree.insert(sin_post_node, "end", values=(rec_id, file_name, file_type, size_str, downloaded_at))
def render_general_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(0, weight=0)
# General frame
general_frame = ctk.CTkFrame(tab, fg_color="transparent")
general_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=20)
general_frame.grid_columnconfigure(1, weight=1)
# Main label
general_label = ctk.CTkLabel(general_frame, text=self.translate("General Options"), font=("Helvetica", 16, "bold"))
general_label.grid(row=0, column=0, columnspan=3, sticky="w")
# Section description
description_label = ctk.CTkLabel(general_frame, text=self.translate("Here you can change the appearance and language of the application."),
font=("Helvetica", 11), text_color="gray")
description_label.grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 15))
# Theme
theme_label = ctk.CTkLabel(general_frame, text=self.translate("Theme"), font=("Helvetica", 14))
theme_label.grid(row=2, column=0, pady=5, sticky="w")
theme_combobox = ctk.CTkComboBox(general_frame, values=["Light", "Dark", "System"], state='readonly', width=120)
theme_combobox.set(self.settings.get('theme', 'System'))
theme_combobox.grid(row=2, column=1, pady=5, padx=(10,0), sticky="w")
apply_theme_button = ctk.CTkButton(general_frame, text=self.translate("Apply Theme"),
command=lambda: self.change_theme_in_thread(theme_combobox.get()))
apply_theme_button.grid(row=2, column=2, pady=5, sticky="e")
# Separator
separator_1 = ttk.Separator(general_frame, orient="horizontal")
separator_1.grid(row=3, column=0, columnspan=3, sticky="ew", pady=15)
# Language
language_label = ctk.CTkLabel(general_frame, text=self.translate("Language"), font=("Helvetica", 14))
language_label.grid(row=4, column=0, pady=5, sticky="w")
language_combobox = ctk.CTkComboBox(general_frame, values=list(self.languages.keys()), state='readonly', width=120)
language_combobox.set(self.get_language_name(self.settings.get('language', 'en')))
language_combobox.grid(row=4, column=1, pady=5, padx=(10,0), sticky="w")
apply_language_button = ctk.CTkButton(general_frame, text=self.translate("Apply Language"),
command=lambda: self.apply_language_settings(language_combobox.get()))
apply_language_button.grid(row=4, column=2, pady=5, sticky="e")
# Separator for updates
separator_2 = ttk.Separator(general_frame, orient="horizontal")
separator_2.grid(row=5, column=0, columnspan=3, sticky="ew", pady=15)
# Check for Updates
update_label = ctk.CTkLabel(general_frame, text=self.translate("Check for Updates"), font=("Helvetica", 14))
update_label.grid(row=6, column=0, pady=5, sticky="w")
check_update_button = ctk.CTkButton(general_frame, text=self.translate("Check Now"),
command=lambda: threading.Thread(target=self.check_for_new_version, args=(False,)).start())
check_update_button.grid(row=6, column=1, pady=5, padx=(10,0), sticky="w")
def render_downloads_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(0, weight=1)
# Frame for the "Downloads" tab
downloads_frame = ctk.CTkFrame(tab, fg_color="transparent")
downloads_frame.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
downloads_frame.grid_columnconfigure(1, weight=1)
download_label = ctk.CTkLabel(downloads_frame, text=self.translate("Download Options"), font=("Helvetica", 16, "bold"))
download_label.grid(row=0, column=0, columnspan=2, sticky="w")
description_label = ctk.CTkLabel(
downloads_frame,
text=self.translate("Here you can adjust the number of simultaneous downloads, file naming, retries, and more."),
font=("Helvetica", 11),
text_color="gray"
)
description_label.grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 15))
# ----------------------------
# Simultaneous downloads
# ----------------------------
max_downloads_label = ctk.CTkLabel(downloads_frame, text=self.translate("Simultaneous Downloads"))
max_downloads_label.grid(row=2, column=0, pady=5, sticky="w")
max_downloads_combobox = ctk.CTkComboBox(
downloads_frame,
values=[str(i) for i in range(1, 11)],
state='readonly',
width=80
)
max_downloads_combobox.set(str(self.settings.get('max_downloads', 3)))
max_downloads_combobox.grid(row=2, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Folder structure
# ----------------------------
folder_structure_label = ctk.CTkLabel(downloads_frame, text=self.translate("Folder Structure"))
folder_structure_label.grid(row=3, column=0, pady=5, sticky="w")
folder_structure_combobox = ctk.CTkComboBox(
downloads_frame,
values=["default", "post_number"],
state='readonly',
width=150
)
folder_structure_combobox.set(self.settings.get('folder_structure', 'default'))
folder_structure_combobox.grid(row=3, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Max retries
# ----------------------------
retry_label = ctk.CTkLabel(downloads_frame, text=self.translate("Max Retries"))
retry_label.grid(row=4, column=0, pady=5, sticky="w")
retry_combobox = ctk.CTkComboBox(
downloads_frame,
values=[str(i) for i in range(0, 11)] + ['999999'],
state='readonly',
width=80
)
retry_combobox.set(str(self.settings.get('max_retries', 3)))
retry_combobox.grid(row=4, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Retry interval
# ----------------------------
retry_interval_label = ctk.CTkLabel(downloads_frame, text=self.translate("Retry Interval (seconds)"))
retry_interval_label.grid(row=5, column=0, pady=5, sticky="w")
retry_interval_entry = ctk.CTkEntry(downloads_frame, width=80)
retry_interval_entry.insert(0, str(self.settings.get('retry_interval', 2.0)))
retry_interval_entry.grid(row=5, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# File naming mode
# ----------------------------
naming_label = ctk.CTkLabel(downloads_frame, text=self.translate("File Naming Mode"))
naming_label.grid(row=6, column=0, pady=5, sticky="w")
naming_options = [
"Use File ID (default)",
"Use Sanitized Post Name",
"Post Name + Post ID Suffix",
"Post Date/Time + Post Name"
]
# 1) Crear PRIMERO el combobox
file_naming_combobox = ctk.CTkComboBox(
downloads_frame,
values=naming_options,
state='readonly',
width=200
)
# 2) Recuperar el valor desde settings (puede ser int o str).
# Si es int (0,1,2,3) mapeamos a la cadena; si es str, la usamos tal cual.
naming_mode = self.settings.get('file_naming_mode', 0)
if isinstance(naming_mode, int):
# Asegurarnos de no pasarnos de índice
if 0 <= naming_mode < len(naming_options):
file_naming_combobox.set(naming_options[naming_mode])
else:
file_naming_combobox.set(naming_options[0])
else:
# Aquí asumes que es una cadena. Si no, fallback a la primera
if naming_mode in naming_options:
file_naming_combobox.set(naming_mode)
else:
file_naming_combobox.set(naming_options[0])
file_naming_combobox.grid(row=6, column=1, pady=5, padx=(10, 0), sticky="w")
# ----------------------------
# Botón para aplicar configuración
# ----------------------------
apply_download_button = ctk.CTkButton(
downloads_frame,
text=self.translate("Apply Download Settings"),
command=lambda: self.apply_download_settings(
max_downloads_combobox,
folder_structure_combobox,
retry_combobox,
retry_interval_entry,
file_naming_combobox
)
)
apply_download_button.grid(row=7, column=1, pady=10, sticky="e")
def render_structure_tab(self, tab):
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(1, weight=1)
structure_label = ctk.CTkLabel(
tab,
text=self.translate("Folder Structure Preview"),
font=("Helvetica", 16, "bold")
)
structure_label.grid(row=0, column=0, pady=(20,10), sticky="w")
# Descripción principal
description_label = ctk.CTkLabel(
tab,
text=self.translate("Here you can see how your downloaded files will be organized on disk."),
font=("Helvetica", 11),
text_color="gray"
)
description_label.grid(row=1, column=0, sticky="w", padx=20, pady=(0,15))
# -------- BLOQUE DE EJEMPLO: Ejemplos de nombres de archivos según la opción --------
examples_text = (
"Ejemplos de nombres según File Naming Mode:\n\n"
" • [Modo 0] Use File ID (default): \n"
" 123456.mp4 (usa el nombre/ID original del archivo)\n\n"
" • [Modo 1] Use Sanitized Post Name: \n"
" Mi_Post_Ejemplo_1_ab12.mp4 (nombre del post + índice del adjunto + hash único)\n\n"
" • [Modo 2] Post Name + Post ID Suffix: \n"
" Mi_Post_Ejemplo - 98765_1.mp4 (nombre del post + ID del post + índice del adjunto)\n"
" • [Modo 3] Post Time + Post Name: \n"
" 2000-01-01T00_00_00 - Mi_Post_Ejemplo - 98765_1.mp4 (post date/time + nombre del post + índice del adjunto + hash único)\n"
)
examples_label = ctk.CTkLabel(
tab,
text=self.translate(examples_text),
font=("Helvetica", 11),
text_color="gray",
justify="left"
)
examples_label.grid(row=2, column=0, sticky="w", padx=20, pady=(0,15))
# ------------------------------------------------------------------------------------
treeview_frame = ctk.CTkFrame(tab, fg_color="transparent")
treeview_frame.grid(row=3, column=0, sticky="nsew", padx=20, pady=20)
treeview_frame.grid_rowconfigure(0, weight=1)
treeview_frame.grid_columnconfigure(0, weight=1)
treeview_frame.grid_columnconfigure(1, weight=1)
self.default_treeview = ttk.Treeview(treeview_frame, show="tree")
self.default_treeview.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.post_treeview = ttk.Treeview(treeview_frame, show="tree")
self.post_treeview.grid(row=0, column=1, sticky="nsew", padx=5, pady=5)
self.update_treeview()
def export_db(self):
db_path = self.downloader.db_path
if os.path.exists(db_path):
export_path = filedialog.asksaveasfilename(defaultextension=".db",
filetypes=[("SQLite DB", "*.db")],
title=self.translate("Export Database"))
if export_path:
try:
import shutil
shutil.copy(db_path, export_path)
messagebox.showinfo(self.translate("Success"), self.translate("The database was exported successfully."))
except Exception as e:
messagebox.showerror(self.translate("Error"), self.translate(f"Error exporting database: {e}"))
else:
messagebox.showwarning(self.translate("Warning"), self.translate("Database not found."))
def clear_db(self):
confirm = messagebox.askyesno(self.translate("Confirm"),
self.translate("Are you sure you want to clear the database? This will delete all download records."))
if confirm:
try:
self.downloader.clear_database()
messagebox.showinfo(self.translate("Success"), self.translate("The database was cleared successfully."))
except Exception as e:
messagebox.showerror(self.translate("Error"), self.translate(f"Error clearing database: {e}"))
def apply_download_settings(self,max_downloads_combobox,folder_structure_combobox,retry_combobox,retry_interval_entry,file_naming_combobox):
try:
max_downloads = int(max_downloads_combobox.get())
folder_structure = folder_structure_combobox.get()
max_retries = int(retry_combobox.get())
retry_interval = float(retry_interval_entry.get())
file_naming_mode_str = file_naming_combobox.get()
mapping = {
"Use File ID (default)": 0,
"Use Sanitized Post Name": 1,
"Post Name + Post ID Suffix": 2,
"Post Date/Time + Post Name": 3
}
numeric_mode = mapping.get(file_naming_mode_str, 0)
self.downloader.file_naming_mode = numeric_mode
self.settings['max_downloads'] = max_downloads
self.settings['folder_structure'] = folder_structure
self.settings['max_retries'] = max_retries
self.settings['retry_interval'] = retry_interval
self.settings['file_naming_mode'] = numeric_mode
self.save_settings()
if self.downloader:
self.downloader.update_max_downloads(max_downloads)
self.downloader.max_retries = max_retries
self.downloader.retry_interval = retry_interval
self.downloader.file_naming_mode = numeric_mode
messagebox.showinfo(
self.translate("Éxito"),
self.translate("La configuración de descargas se aplicó correctamente.")
)
except ValueError:
messagebox.showerror(
self.translate("Error"),
self.translate("Por favor, ingresa valores numéricos válidos.")
)
def apply_language_settings(self, selected_language_name):
if selected_language_name in self.languages:
selected_language_code = self.languages[selected_language_name]
self.settings['language'] = selected_language_code
self.save_settings()
self.save_language_preference(selected_language_code)
self.load_translations(selected_language_code)
self.update_ui_texts()
messagebox.showinfo(self.translate("Success"), self.translate("The language was applied successfully."))
else:
messagebox.showwarning(self.translate("Warning"), self.translate("Please select a language."))
def update_treeview(self):
"""Update the TreeViews to display the folder structure."""
if hasattr(self, 'default_treeview') and hasattr(self, 'post_treeview'):
self.default_treeview.delete(*self.default_treeview.get_children())
self.post_treeview.delete(*self.post_treeview.get_children())
root = self.default_treeview.insert("", "end", text="User", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="images", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="videos", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="documents", image=self.folder_structure_icons.get('folder'))
self.default_treeview.insert(root, "end", text="compressed", image=self.folder_structure_icons.get('folder'))
self.default_treeview.item(root, open=True)
post_root = self.post_treeview.insert("", "end", text="User", image=self.folder_structure_icons.get('folder'))
post = self.post_treeview.insert(post_root, "end", text="post_id", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="images", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="videos", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="documents", image=self.folder_structure_icons.get('folder'))
self.post_treeview.insert(post, "end", text="compressed", image=self.folder_structure_icons.get('folder'))
self.post_treeview.item(post_root, open=True)
def clear_frame(self, frame):
for widget in frame.winfo_children():
widget.destroy()
def get_language_name(self, lang_code):
for name, code in self.languages.items():
if code == lang_code:
return name
return "English"
def change_theme_in_thread(self, theme_name):
threading.Thread(target=self.apply_theme, args=(theme_name,)).start()
def apply_theme(self, theme_name):
if theme_name.lower() == "light":
ctk.set_appearance_mode("light")
elif theme_name.lower() == "dark":
ctk.set_appearance_mode("dark")
else:
ctk.set_appearance_mode("system")
self.settings['theme'] = theme_name
self.save_settings()
messagebox.showinfo(self.translate("Success"), self.translate("The theme was applied successfully."))
def center_window(self, window, width, height):
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = int((screen_width / 2) - (width / 2))
y = int((screen_height / 2) - (height / 2))
window.geometry(f'{width}x{height}+{x}+{y}')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,359 @@
import hashlib
import os
import requests
import time
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse, urljoin
import uuid
import re
import threading
class BunkrDownloader:
def __init__(self, download_folder, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, headers=None, max_workers=5, translations=None):
self.download_folder = download_folder
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.session = requests.Session()
self.headers = headers or {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
'Referer': 'https://bunkr.site/',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'Accept-Language': 'en-US,en;q=0.9',
}
self.cancel_requested = False # Flag to indicate if a cancellation request has been made
self.executor = ThreadPoolExecutor(max_workers=max_workers) # Thread pool executor for concurrent downloads
self.total_files = 0
self.completed_files = 0
self.max_downloads = 5 # Valor por defecto
self.log_messages = [] # Cola para almacenar mensajes de log
self.notification_interval = 10 # Intervalo de notificación en segundos
self.start_notification_thread()
self.translations = translations or {}
def start_notification_thread(self):
def notify_user():
while not self.cancel_requested:
if self.log_messages:
# Enviar todos los mensajes acumulados
if self.log_callback:
self.log_callback("\n".join(self.log_messages))
self.log_messages.clear()
time.sleep(self.notification_interval)
# Iniciar un hilo para notificaciones periódicas
notification_thread = threading.Thread(target=notify_user, daemon=True)
notification_thread.start()
def tr(self, key):
# Obtener la traducción para la clave dada
return self.translations.get(key, key)
def log(self, message_key, url=None):
message = self.tr(message_key)
domain = urlparse(url).netloc if url else "General"
full_message = f"{domain}: {message}"
self.log_messages.append(full_message) # Agregar mensaje a la cola
def request_cancel(self):
self.cancel_requested = True
self.log("Download has been cancelled.")
self.shutdown_executor()
def shutdown_executor(self):
self.executor.shutdown(wait=False)
self.log("Executor shut down.")
def clean_filename(self, filename):
return re.sub(r'[<>:"/\\|?*\u200b]', '_', filename)
def get_consistent_folder_name(self, url, default_name):
# Genera un hash de la URL para crear un nombre único y consistente
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
folder_name = f"{default_name}_{url_hash}"
return self.clean_filename(folder_name)
def download_file(self, url_media, ruta_carpeta, file_id):
if self.cancel_requested:
self.log("Descarga cancelada", url=url_media)
return
file_name = os.path.basename(urlparse(url_media).path)
file_path = os.path.join(ruta_carpeta, file_name)
if os.path.exists(file_path):
self.log(f"El archivo ya existe, omitiendo: {file_path}")
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
return
max_attempts = 3
delay = 1
for attempt in range(max_attempts):
try:
self.log(f"Intentando descargar {url_media} (Intento {attempt + 1}/{max_attempts})")
response = self.session.get(url_media, headers=self.headers, stream=True)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
# Descargar el archivo en fragmentos
with open(file_path, 'wb') as file:
for chunk in response.iter_content(chunk_size=65536): # Fragmentos de 64KB
if self.cancel_requested:
self.log("Descarga cancelada durante la descarga del archivo.", url=url_media)
file.close()
os.remove(file_path)
return
file.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
self.update_progress_callback(downloaded_size, total_size, file_id=file_id, file_path=file_path)
self.log("Archivo descargado", url=url_media)
# Notificar al usuario al completar la descarga
if self.log_callback:
self.log_callback(f"Descarga completada: {file_name}")
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
break
except requests.RequestException as e:
if response.status_code == 429:
self.log(f"Límite de tasa excedido. Reintentando después de {delay} segundos.")
time.sleep(delay)
delay *= 2 # Retroceso exponencial para limitación de tasa
else:
self.log(f"Error al descargar de {url_media}: {e}. Intento {attempt + 1} de {max_attempts}", url=url_media)
if attempt < max_attempts - 1:
time.sleep(3)
def descargar_post_bunkr(self, url_post):
try:
self.log(f"Iniciando descarga para el post: {url_post}")
# Si se trata de una URL tipo '/f/', seguimos el flujo en dos pasos:
if '/f/' in url_post:
self.log("Detectado URL tipo '/f/'. Procediendo a extraer el enlace intermedio.")
# Paso 1: Accedemos a la URL original para obtener el primer enlace (intermedio)
response = self.session.get(url_post, headers=self.headers)
if response.status_code != 200:
self.log(f"Error al acceder al post {url_post}: Estado {response.status_code}")
return
soup = BeautifulSoup(response.text, 'html.parser')
first_anchor = soup.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold flex-1 ic-download-01 ic-before before:text-lg'
})
if not first_anchor or 'href' not in first_anchor.attrs:
self.log("No se encontró el primer enlace de descarga en la página original.")
return
intermediate_url = first_anchor['href']
self.log(f"Enlace intermedio encontrado: {intermediate_url}")
# Paso 2: Accedemos a la URL intermedia para extraer el enlace final de descarga
intermediate_response = self.session.get(intermediate_url, headers=self.headers)
if intermediate_response.status_code != 200:
self.log(f"Error al acceder a la URL intermedia: {intermediate_url} (Estado {intermediate_response.status_code})")
return
soup2 = BeautifulSoup(intermediate_response.text, 'html.parser')
# Buscamos la etiqueta <p> con la clase esperada y, dentro, el <a> con el enlace final
p_tag = soup2.find('p', class_="mt-3 text-center")
if not p_tag:
self.log("No se encontró la etiqueta <p> con clase 'mt-3 text-center' en la página intermedia.")
return
download_anchor = p_tag.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold ic-download-01 ic-before before:text-lg'
})
if not download_anchor or 'href' not in download_anchor.attrs:
self.log("No se encontró el enlace de descarga final en la página intermedia.")
return
final_download_url = download_anchor['href']
self.log(f"Enlace de descarga final encontrado: {final_download_url}")
# Creamos la carpeta de destino para este post
file_name = "bunkr_post"
folder_name = self.get_consistent_folder_name(url_post, file_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
# Preparamos la lista de medios con el enlace final
media_urls = [(final_download_url, ruta_carpeta)]
else:
# Lógica original para posts que contienen imágenes y videos
response = self.session.get(url_post, headers=self.headers)
if response.status_code != 200:
self.log(f"Error al acceder al post {url_post}: Estado {response.status_code}")
return
soup = BeautifulSoup(response.text, 'html.parser')
# Extraer y sanitizar el nombre de la carpeta para el post
file_name_tag = soup.find('h1', {'class': 'truncate'})
if file_name_tag:
file_name = file_name_tag.text.strip()
file_name = self.clean_filename(file_name)[:50] # Limitar a 50 caracteres
else:
file_name = "bunkr_post"
folder_name = self.get_consistent_folder_name(url_post, file_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
media_urls = []
# Buscar imágenes en etiquetas <figure>
media_divs = soup.find_all('figure', {'class': 'relative rounded-lg overflow-hidden flex justify-center items-center aspect-video bg-soft'})
for div in media_divs:
img_tags = div.find_all('img')
for img_tag in img_tags:
if 'src' in img_tag.attrs:
img_url = img_tag['src']
self.log(f"URL de imagen encontrada: {img_url}")
media_urls.append((img_url, ruta_carpeta))
# Buscar videos: se recorre cada div que pueda contener el enlace intermedio de descarga
video_divs = soup.find_all('div', {'class': 'flex w-full md:w-auto gap-4'})
self.log(f"Se encontraron {len(video_divs)} divs de video.")
for video_div in video_divs:
self.log("Buscando enlace de página de descarga en el div de video.")
download_page_link = video_div.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold flex-1 ic-download-01 ic-before before:text-lg'
})
if download_page_link and 'href' in download_page_link.attrs:
video_page_url = download_page_link['href']
self.log(f"URL de la página de descarga encontrada: {video_page_url}. Accediendo ahora.")
video_page_response = self.session.get(video_page_url, headers=self.headers)
self.log(f"Estado de la respuesta de la página de video: {video_page_response.status_code} para {video_page_url}")
if video_page_response.status_code == 200:
video_page_soup = BeautifulSoup(video_page_response.text, 'html.parser')
self.log("Buscando enlace de descarga real en la página de video.")
download_link = video_page_soup.find('a', {
'class': 'btn btn-main btn-lg rounded-full px-6 font-semibold ic-download-01 ic-before before:text-lg'
})
if download_link and 'href' in download_link.attrs:
video_url = download_link['href']
self.log(f"URL de descarga de video encontrada: {video_url}")
media_urls.append((video_url, ruta_carpeta))
else:
self.log(f"No se encontró enlace de descarga en la página de video: {video_page_url}")
else:
self.log(f"Error al acceder a la página de video: {video_page_url} con estado {video_page_response.status_code}")
# Proceder a la descarga de todos los medios encontrados
self.total_files = len(media_urls)
if media_urls: # Solo proceder si hay URLs para descargar
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=self.max_downloads) as executor:
futures = [executor.submit(self.download_file, url, folder, str(uuid.uuid4())) for url, folder in media_urls]
for future in as_completed(futures):
if self.cancel_requested:
self.log("Cancelando descargas restantes.")
break
future.result()
self.log("Descarga iniciada para todos los medios.")
if self.enable_widgets_callback:
self.enable_widgets_callback()
except Exception as e:
self.log(f"Error al procesar el post {url_post}: {e}")
if self.enable_widgets_callback:
self.enable_widgets_callback()
def descargar_perfil_bunkr(self, url_perfil):
try:
self.log(f"Iniciando descarga para el perfil: {url_perfil}")
response = self.session.get(url_perfil, headers=self.headers)
self.log(f"Código de estado de la respuesta: {response.status_code} para {url_perfil}")
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
# Extraer y sanitizar el nombre de la carpeta para el perfil
file_name_tag = soup.find('h1', {'class': 'truncate'})
if file_name_tag:
folder_name = file_name_tag.text.strip()
else:
folder_name = "bunkr_profile"
# Usar el nuevo método para obtener un nombre de carpeta consistente
folder_name = self.get_consistent_folder_name(url_perfil, folder_name)
ruta_carpeta = os.path.join(self.download_folder, folder_name)
os.makedirs(ruta_carpeta, exist_ok=True)
# Find media URLs in the profile
media_urls = []
grid_div = soup.find('div', {'class': 'grid gap-4 grid-cols-repeat [--size:11rem] lg:[--size:14rem] grid-images'})
if grid_div:
links = grid_div.find_all('a', {'class': 'after:absolute after:z-10 after:inset-0'})
total_links = len(links)
for idx, link in enumerate(links):
if self.cancel_requested:
self.log("Cancelling remaining downloads.")
break
# Resolve relative URLs to full URLs
image_page_url = urljoin(url_perfil, link['href'])
self.log(f"Processing media page URL: {image_page_url}")
# Visit the page to get the media URL
image_response = self.session.get(image_page_url, headers=self.headers)
if image_response.status_code == 200:
image_soup = BeautifulSoup(image_response.text, 'html.parser')
# Search for image URL
media_tag = image_soup.select_one("figure.relative img[class='w-full h-full absolute opacity-20 object-cover blur-sm z-10']")
if media_tag and 'src' in media_tag.attrs:
media_url = urljoin(image_page_url, media_tag['src']) # Resolve media URL
self.log(f"Found image URL: {media_url}")
media_urls.append((media_url, ruta_carpeta))
# Search for video URL
video_tag = image_soup.select_one("video#player")
if video_tag and 'src' in video_tag.attrs:
video_url = urljoin(image_page_url, video_tag['src']) # Resolve video URL
self.log(f"Found video URL: {video_url}")
media_urls.append((video_url, ruta_carpeta))
else:
source_tag = video_tag.find('source') if video_tag else None
if source_tag and 'src' in source_tag.attrs:
video_url = urljoin(image_page_url, source_tag['src']) # Resolve video URL from source
self.log(f"Found video URL from source: {video_url}")
media_urls.append((video_url, ruta_carpeta))
self.total_files = len(media_urls)
futures = [self.executor.submit(self.download_file, url, folder, str(uuid.uuid4())) for url, folder in media_urls]
# Only after all futures are done, enable widgets again
for future in as_completed(futures):
if self.cancel_requested:
self.log("Cancelling remaining downloads.")
break
future.result()
self.log("Download completed for all media.")
if self.enable_widgets_callback:
self.enable_widgets_callback() # Only enable after all downloads are done
else:
self.log(f"Failed to access the profile {url_perfil}: Status {response.status_code}")
except Exception as e:
self.log(f"Failed to access the profile {url_perfil}: {e}")
if self.enable_widgets_callback:
self.enable_widgets_callback()
def set_max_downloads(self, max_downloads):
self.max_downloads = max_downloads
@@ -0,0 +1,724 @@
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Semaphore
from urllib.parse import quote_plus, urlencode, urljoin, urlparse
import os
import re
import requests
import threading
import time
import sqlite3
class Downloader:
def __init__(self, download_folder, max_workers=5, log_callback=None,
enable_widgets_callback=None, update_progress_callback=None,
update_global_progress_callback=None, headers=None,
max_retries=999999, retry_interval=1.0, stream_read_timeout=10,
download_images=True, download_videos=True, download_compressed=True,
tr=None, folder_structure='default', rate_limit_interval=1.0):
self.download_folder = download_folder
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.cancel_requested = threading.Event()
self.headers = headers or {
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
'Referer': 'https://coomer.st/',
"Accept": "text/css"
}
self.media_counter = 0
self.session = requests.Session()
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
self.rate_limit = Semaphore(self.max_workers)
self.domain_locks = defaultdict(lambda: Semaphore(self.max_workers))
self.domain_last_request = defaultdict(float)
self.rate_limit_interval = rate_limit_interval
self.download_mode = "multi"
self.video_extensions = ('.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.wmv', '.m4v')
self.image_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff')
self.document_extensions = ('.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx')
self.compressed_extensions = ('.zip', '.rar', '.7z', '.tar', '.gz')
self.download_images = download_images
self.download_videos = download_videos
self.download_compressed = download_compressed
self.futures = []
self.total_files = 0
self.completed_files = 0
self.skipped_files = []
self.failed_files = []
self.start_time = None
self.tr = tr
self.shutdown_called = False
self.folder_structure = folder_structure
self.failed_retry_count = {}
self.max_retries = max_retries
self.retry_interval = retry_interval
self.file_lock = threading.Lock()
self.post_attachment_counter = defaultdict(int)
self.subdomain_cache = {}
self.subdomain_locks = defaultdict(threading.Lock)
self.stream_read_timeout = stream_read_timeout
db_folder = os.path.join("resources", "config")
os.makedirs(db_folder, exist_ok=True)
self.db_path = os.path.join(db_folder, "downloads.db")
self.db_lock = threading.Lock()
self.init_db()
self.load_download_cache()
def init_db(self):
self.db_connection = sqlite3.connect(self.db_path, check_same_thread=False)
self.db_cursor = self.db_connection.cursor()
self.db_cursor.execute("""
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_url TEXT UNIQUE,
file_path TEXT,
file_size INTEGER,
user_id TEXT,
post_id TEXT,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
self.db_connection.commit()
def load_download_cache(self):
with self.db_lock:
self.db_cursor.execute("SELECT media_url, file_path, file_size FROM downloads")
rows = self.db_cursor.fetchall()
self.download_cache = {row[0]: (row[1], row[2]) for row in rows}
def log(self, message):
if self.log_callback:
self.log_callback(self.tr(message) if self.tr else message)
def set_download_mode(self, mode, max_workers):
if mode == 'queue':
max_workers = 1
self.download_mode = mode
self.max_workers = max_workers
if self.executor:
self.executor.shutdown(wait=True)
self.executor = ThreadPoolExecutor(max_workers=max_workers)
self.rate_limit = Semaphore(max_workers)
self.log(f"Updated download mode to {mode} with max_workers = {max_workers}")
def set_retry_settings(self, max_retries, retry_interval):
self.max_retries = max_retries
self.rate_limit_interval = retry_interval
def request_cancel(self):
self.cancel_requested.set()
self.log(self.tr("Download cancellation requested."))
for future in self.futures:
future.cancel()
def shutdown_executor(self):
if not self.shutdown_called:
self.shutdown_called = True
if self.executor:
self.executor.shutdown(wait=True)
if self.enable_widgets_callback:
self.enable_widgets_callback()
self.log(self.tr("All downloads completed or cancelled."))
def safe_request(self, url, max_retries=None, headers=None):
if max_retries is None:
max_retries = self.max_retries
if headers is None:
headers = self.headers
parsed = urlparse(url)
domain = parsed.netloc
path = parsed.path
for attempt in range(max_retries + 1):
if self.cancel_requested.is_set():
return None
with self.domain_locks[domain]:
elapsed_time = time.time() - self.domain_last_request[domain]
if elapsed_time < self.rate_limit_interval:
time.sleep(self.rate_limit_interval - elapsed_time)
try:
self.domain_last_request[domain] = time.time()
response = self.session.get(url, stream=True, headers=headers, timeout=self.stream_read_timeout)
sc = response.status_code
if sc in (403, 404) and ("coomer" in domain or "kemono" in domain):
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"{sc} - probing subdomains")
with self.subdomain_locks[path]:
if path in self.subdomain_cache:
alt_url = self.subdomain_cache[path]
else:
alt_url = self._find_valid_subdomain(url)
self.subdomain_cache[path] = alt_url
if alt_url != url:
found = urlparse(alt_url).netloc
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Subdomain found: {found}")
response = self.session.get(alt_url, stream=True, headers=headers)
response.raise_for_status()
return response
else:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status="Exhausted subdomains")
return None
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
status_code = getattr(e.response, 'status_code', None)
if status_code in (429, 500, 502, 503, 504):
self.log(log_message)
time.sleep(self.retry_interval)
elif status_code not in (403, 404):
url_display = getattr(e.request, 'url', url)
if len(url_display) > 60:
url_display = url_display[:60] + "..."
self.log(self.tr("Intento {attempt}/{max_retries_val}: Error al acceder a {url} - {error}").format(
attempt=attempt + 1, max_retries_val=max_retries + 1, url=url_display, error=e))
if attempt < max_retries:
time.sleep(self.retry_interval)
else:
if isinstance(e, requests.exceptions.ReadTimeout):
self.log(self.tr("Intento {attempt}/{max_retries_val}: Read timeout ({stream_timeout}s) - Reintentando...").format(
attempt=attempt + 1,
max_retries_val=max_retries + 1,
stream_timeout=self.stream_read_timeout
))
time.sleep(self.retry_interval)
else:
log_message = self.tr("Intento {attempt}/{max_retries_val}: Error {status_code} - Reintentando...").format(
attempt=attempt + 1, max_retries_val=max_retries + 1, status_code=status_code)
if status_code in (403, 404) and ("coomer" in domain or "kemono" in domain) and attempt == max_retries:
self.log(self.tr("Fallo final al acceder a {url} con error {status_code}").format(url=url, status_code=status_code))
return None
def _find_valid_subdomain(self, url, max_subdomains=10):
parsed = urlparse(url)
original_path = parsed.path
path = original_path
if not original_path.startswith("/data/"):
path = ("/data" + original_path) if not original_path.startswith("/data") else original_path
host = parsed.netloc
if "coomer" in host:
base_domains = ["coomer.st"]
elif "kemono" in host:
base_domains = ["kemono.cr", "kemono.su"]
else:
base_domains = [host]
for base in base_domains:
for i in range(1, max_subdomains + 1):
domain = f"n{i}.{base}"
test_url = parsed._replace(netloc=domain, path=path).geturl()
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Testing subdomain: {domain}")
try:
resp = self.session.get(test_url, headers=self.headers,
timeout=self.stream_read_timeout, stream=True)
if resp.status_code == 200:
return test_url
else:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Invalid subdomain: {domain}")
except requests.exceptions.ReadTimeout:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Timeout in: {domain}")
except Exception:
if self.update_progress_callback:
self.update_progress_callback(0, 0, status=f"Invalid subdomain: {domain}")
return url
def fetch_user_posts(self, site, user_id, service, query=None, specific_post_id=None, initial_offset=0, log_fetching=True):
all_posts = []
offset = initial_offset
user_id_encoded = quote_plus(user_id)
while True:
if self.cancel_requested.is_set():
return all_posts
api_url = f"https://{site}/api/v1/{service}/user/{user_id_encoded}/posts"
url_query = {"o": offset}
if query not in (None, "", 0, "0"):
url_query["q"] = query
api_url += "?" + urlencode(url_query)
if log_fetching:
self.log(self.tr("Fetching user posts from {api_url}", api_url=api_url))
try:
response = self.session.get(api_url, headers=self.headers)
if response.status_code == 400:
self.log(self.tr("End of posts at offset {offset}.", offset=offset))
break
response.raise_for_status()
try:
posts_data = response.json()
except ValueError as e:
self.log(self.tr("Error al parsear JSON: {e}", e=e))
break
if isinstance(posts_data, dict) and 'data' in posts_data:
posts = posts_data['data']
else:
posts = posts_data
if not posts:
break
if specific_post_id:
post = next((p for p in posts if p['id'] == specific_post_id), None)
if post:
return [post]
all_posts.extend(posts)
offset += 50
except Exception as e:
self.log(self.tr("Error fetching user posts: {e}", e=e))
break
if specific_post_id:
return [post for post in all_posts if post['id'] == specific_post_id]
return all_posts
def get_filename(self, media_url, post_id=None, post_name=None, attachment_index=1, post_time=None):
base_name = os.path.basename(media_url).split('?')[0]
name_no_ext, extension = os.path.splitext(base_name)
if not hasattr(self, 'file_naming_mode'):
self.file_naming_mode = 0
mode = self.file_naming_mode
def sanitize(name):
sanitized = self.sanitize_filename(name)
return sanitized.strip()
if mode == 0:
sanitized = sanitize(name_no_ext)
if not sanitized:
sanitized = "file"
final_name = f"{sanitized}_{attachment_index}{extension}"
elif mode == 1:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
short_hash = f"{hash(media_url) & 0xFFFF:04x}"
final_name = f"{sanitized_post}_{attachment_index}_{short_hash}{extension}"
elif mode == 2:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
if post_id:
final_name = f"{sanitized_post} - {post_id}_{attachment_index}{extension}"
else:
final_name = f"{sanitized_post}_{attachment_index}{extension}"
elif mode == 3:
sanitized_post = sanitize(post_name or "")
if not sanitized_post:
sanitized_post = f"post_{post_id}" if post_id else "post"
sanitized_time = sanitize(post_time or "")
short_hash = f"{hash(media_url) & 0xFFFF:04x}"
final_name = f"{sanitized_time} - {sanitized_post}_{attachment_index}_{short_hash}{extension}"
else:
final_name = sanitize(name_no_ext) + extension
return final_name
def process_post(self, post, site):
base = f"https://{site}/"
def _full(path):
if not path:
return None
p = path if str(path).startswith('/') else f'/{path}'
return urljoin(base, p)
media_urls = []
f = post.get('file') or {}
u = _full(f.get('path') or f.get('url') or f.get('name'))
if u:
media_urls.append(u)
for att in (post.get('attachments') or []):
u = _full(att.get('path') or att.get('url') or att.get('name'))
if u:
media_urls.append(u)
return media_urls
def sanitize_filename(self, filename):
return re.sub(r'[<>:"/\\|?*]', '_', filename)
def get_media_folder(self, extension, user_id, post_id=None):
if extension in self.video_extensions:
folder_name = "videos"
elif extension in self.image_extensions:
folder_name = "images"
elif extension in self.document_extensions:
folder_name = "documents"
elif extension in self.compressed_extensions:
folder_name = "compressed"
else:
folder_name = "other"
if self.folder_structure == 'post_number' and post_id:
media_folder = os.path.join(self.download_folder, user_id, f'post_{post_id}', folder_name)
else:
media_folder = os.path.join(self.download_folder, user_id, folder_name)
return media_folder
def process_media_element(self, media_url, user_id, post_id=None,
post_name=None, post_time=None, download_id=None):
if self.cancel_requested.is_set():
return
extension = os.path.splitext(media_url)[1].lower()
if (extension in self.image_extensions and not self.download_images) or \
(extension in self.video_extensions and not self.download_videos) or \
(extension in self.compressed_extensions and not self.download_compressed):
self.log(f"Skipping {media_url} due to settings.")
return
if post_id:
self.post_attachment_counter[post_id] += 1
attachment_index = self.post_attachment_counter[post_id]
else:
attachment_index = 1
filename = self.get_filename(media_url, post_id=post_id, post_name=post_name, post_time=post_time,
attachment_index=attachment_index)
media_folder = self.get_media_folder(extension, user_id, post_id)
os.makedirs(media_folder, exist_ok=True)
final_path = os.path.normpath(os.path.join(media_folder, filename))
tmp_path = final_path + ".tmp"
if media_url in self.download_cache:
self.log(f"File from {media_url} is in DB, skipping.")
with self.file_lock:
self.skipped_files.append(final_path)
return
self.log(f"Starting download from {media_url}")
for attempt in range(self.max_retries + 1):
if self.cancel_requested.is_set():
if os.path.exists(tmp_path):
os.remove(tmp_path)
self.log(f"Download cancelled from {media_url}")
return
response = self.safe_request(media_url, max_retries=self.max_retries)
if response is None:
if attempt < self.max_retries:
self.log(f"Initial request failed for {media_url}. Resuming download in {self.retry_interval}s. (Attempt {attempt+1}/{self.max_retries + 1})")
time.sleep(self.retry_interval)
continue
else:
break
try:
try:
total_size = int(response.headers.get('content-length', 0))
except Exception as e:
self.log(f"Error getting total size: {e}")
total_size = 0
downloaded_size = 0
self.start_time = time.time()
with open(tmp_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=1048576):
if self.cancel_requested.is_set():
raise Exception("Cancellation Requested")
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
elapsed_time = time.time() - self.start_time
speed = downloaded_size / elapsed_time if elapsed_time > 0 else 0
remaining_time = (total_size - downloaded_size) / speed if speed > 0 else 0
self.update_progress_callback(downloaded_size, total_size,
file_id=download_id,
file_path=tmp_path,
speed=speed,
eta=remaining_time)
while total_size and downloaded_size < total_size:
resume_headers = self.headers.copy()
resume_headers['Range'] = f'bytes={downloaded_size}-'
self.log(f"Resuming download at byte {downloaded_size} for {media_url}")
part_response = self.safe_request(media_url, max_retries=self.max_retries, headers=resume_headers)
if part_response is None:
raise Exception("Resumption Failed after retries")
with open(tmp_path, 'ab') as f:
for chunk in part_response.iter_content(chunk_size=1048576):
if self.cancel_requested.is_set():
raise Exception("Cancellation Requested")
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
elapsed_time = time.time() - self.start_time
speed = downloaded_size / elapsed_time if elapsed_time > 0 else 0
remaining_time = (total_size - downloaded_size) / speed if speed > 0 else 0
self.update_progress_callback(downloaded_size, total_size,
file_id=download_id,
file_path=tmp_path,
speed=speed,
eta=remaining_time)
if total_size > 0 and downloaded_size != total_size:
raise Exception(f"Final size mismatch: expected {total_size}, got {downloaded_size}")
with self.file_lock:
if os.path.exists(final_path):
os.remove(final_path)
os.rename(tmp_path, final_path)
with self.file_lock:
self.completed_files += 1
self.log(f"Download success from {media_url}")
if self.update_global_progress_callback:
self.update_global_progress_callback(self.completed_files, self.total_files)
with self.db_lock:
self.db_cursor.execute(
"""INSERT OR REPLACE INTO downloads (media_url, file_path, file_size, user_id, post_id)
VALUES (?, ?, ?, ?, ?)""",
(media_url, final_path, total_size, user_id, post_id)
)
self.db_connection.commit()
self.download_cache[media_url] = (final_path, total_size)
return
except Exception as e:
if str(e) == "Cancellation Requested":
if os.path.exists(tmp_path):
os.remove(tmp_path)
self.log(f"Download cancelled from {media_url}")
return
if attempt < self.max_retries:
time.sleep(self.retry_interval)
continue
self.log(f"Failed to download {media_url} after {self.max_retries + 1} total download attempts.")
with self.file_lock:
self.failed_files.append(media_url)
def get_remote_file_size(self, media_url, filename):
try:
response = requests.head(media_url, allow_redirects=True)
if response.status_code == 200:
size = int(response.headers.get('Content-Length', 0))
return media_url, filename, size
else:
self.log(self.tr(f"Failed to get size for {filename}: HTTP {response.status_code}"))
return media_url, filename, None
except Exception as e:
self.log(self.tr(f"Error getting size for {filename}: {e}"))
return media_url, filename, None
def download_media(self, site, user_id, service, query=None, download_all=False, initial_offset=0):
try:
self.log(self.tr("Starting download process..."))
posts = self.fetch_user_posts(
site, user_id, service,
query=query,
initial_offset=initial_offset,
log_fetching=download_all
)
if not posts:
self.log(self.tr("No posts found for this user."))
return
if not download_all:
posts = posts[:50]
self.total_files = 0
for post in posts:
current_post_id = post.get('id') or "unknown_id"
title = post.get('title') or ""
media_urls = self.process_post(post, site)
for media_url in media_urls:
ext = os.path.splitext(media_url)[1].lower()
if (ext in self.image_extensions and not self.download_images) or \
(ext in self.video_extensions and not self.download_videos) or \
(ext in self.compressed_extensions and not self.download_compressed):
continue
self.total_files += 1
futures = []
for post in posts:
current_post_id = post.get('id') or "unknown_id"
title = post.get('title') or ""
time = post.get('published') or ""
media_urls = self.process_post(post, site)
for media_url in media_urls:
ext = os.path.splitext(media_url)[1].lower()
if (ext in self.image_extensions and not self.download_images) or \
(ext in self.video_extensions and not self.download_videos) or \
(ext in self.compressed_extensions and not self.download_compressed):
continue
if self.download_mode == 'queue':
self.process_media_element(
media_url,
user_id,
post_id=current_post_id,
post_name=title,
post_time=time
)
else:
future = self.executor.submit(
self.process_media_element,
media_url,
user_id,
current_post_id,
title,
time,
media_url
)
futures.append(future)
if self.download_mode == 'multi':
for future in as_completed(futures):
if self.cancel_requested.is_set():
break
except Exception as e:
self.log(self.tr(f"Error during download: {e}"))
finally:
self.shutdown_executor()
def download_single_post(self, site, post_id, service, user_id):
try:
post = self.fetch_user_posts(site, user_id, service, specific_post_id=post_id)
if not post:
self.log(self.tr("No post found for this ID."))
return
media_urls = self.process_post(post[0], site)
futures = []
grouped_media_urls = defaultdict(list)
for media_url in media_urls:
grouped_media_urls[post[0]['id']].append(media_url)
self.total_files = len(media_urls)
self.completed_files = 0
for post_id, media_urls in grouped_media_urls.items():
for media_url in media_urls:
if self.download_mode == 'queue':
self.process_media_element(media_url, user_id, post_id)
else:
future = self.executor.submit(self.process_media_element, media_url, user_id, post_id)
futures.append(future)
if self.download_mode == 'multi':
for future in as_completed(futures):
if self.cancel_requested.is_set():
break
except Exception as e:
self.log(self.tr(f"Error during download: {e}"))
finally:
self.shutdown_executor()
def fetch_single_post(self, site, post_id, service):
api_url = f"https://{site}/api/v1/{service}/post/{post_id}"
self.log(self.tr(f"Fetching post from {api_url}"))
try:
with self.rate_limit:
response = self.session.get(api_url, headers=self.headers)
response.raise_for_status()
return response.json()
except Exception as e:
self.log(self.tr(f"Error fetching post: {e}"))
return None
def clear_database(self):
with self.db_lock:
self.db_cursor.execute("DELETE FROM downloads")
self.db_connection.commit()
self.log(self.tr("Database cleared."))
def update_max_downloads(self, new_max):
if self.executor:
self.executor.shutdown(wait=True)
self.max_workers = new_max
self.executor = ThreadPoolExecutor(max_workers=new_max)
self.rate_limit = Semaphore(new_max)
self.log(f"Updated max_workers to {new_max}")
@@ -0,0 +1,287 @@
import re
import uuid
import requests
import os
import time
import datetime
from bs4 import BeautifulSoup
from urllib.parse import urljoin, quote
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from requests.exceptions import ChunkedEncodingError
from tkinter import messagebox, simpledialog
class EromeDownloader:
def __init__(self, root, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, download_images=True, download_videos=True, headers=None, language="en", is_profile_download=False, direct_download=False, tr=None, max_workers=5):
self.root = root
self.session = requests.Session()
self.headers = {k: str(v).encode('ascii', 'ignore').decode('ascii') for k, v in (headers or {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}).items()}
self.log_messages = [] # Store log messages
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.download_images = download_images
self.download_videos = download_videos
self.cancel_requested = False
self.language = language
self.executor = ThreadPoolExecutor(max_workers=max_workers) # Thread pool for concurrent downloads
self.total_files = 0
self.completed_files = 0
self.is_profile_download = is_profile_download
self.direct_download = direct_download # Option for direct downloads without folder creation
self.tr = tr if tr else lambda x, **kwargs: x.format(**kwargs) # Translation function
def request_cancel(self):
self.cancel_requested = True
self.log(self.tr("Download cancelled"))
if self.is_profile_download:
self.enable_widgets_callback()
def log(self, message):
if self.log_callback is not None:
self.log_callback(message)
self.log_messages.append(message)
def shutdown_executor(self):
self.executor.shutdown(wait=False)
self.log(self.tr("Executor shut down."))
if self.is_profile_download:
self.enable_widgets_callback()
@staticmethod
def clean_filename(filename):
return re.sub(r'[<>:"/\\|?*]', '_', filename.split('?')[0])
def create_folder(self, folder_name):
try:
os.makedirs(folder_name, exist_ok=True)
except OSError as e:
self.log(self.tr("Error creating folder: {error}", error=e))
response = messagebox.askyesno(self.tr("Error"), self.tr("Couldn't create folder: {folder_name}\nWould you like to choose a new name?", folder_name=folder_name), parent=self.root)
if response:
new_folder_name = simpledialog.askstring(self.tr("New folder name"), self.tr("Enter new folder name:"), parent=self.root)
if new_folder_name:
folder_name = os.path.join(os.path.dirname(folder_name), self.clean_filename(new_folder_name))
try:
os.makedirs(folder_name, exist_ok=True)
except OSError as e:
messagebox.showerror(self.tr("Error"), self.tr("Could not create folder: {folder_name}\nError: {error}", folder_name=folder_name, error=e), parent=self.root)
return folder_name
def download_file(self, url, file_path, resource_type, file_id=None, max_retries=999999):
if self.cancel_requested:
return
# Evita sobreescrituras y descargas duplicadas
if os.path.exists(file_path):
self.log(self.tr("File already exists, skipping: {file_path}",
file_path=file_path))
return
os.makedirs(os.path.dirname(file_path), exist_ok=True)
self.log(self.tr("Start downloading {resource_type}: {file_path}",
resource_type=resource_type, file_path=file_path))
retries = 0
while retries <= max_retries:
try:
with requests.get(url, headers=self.headers,
stream=True, timeout=15) as response:
if response.status_code != 200:
self.log(self.tr("Error downloading {resource_type}, "
"status code: {status_code}",
resource_type=resource_type,
status_code=response.status_code))
break
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = last_update = time.time()
with open(file_path, "wb") as f:
for chunk in response.iter_content(chunk_size=65536):
if self.cancel_requested:
return
f.write(chunk)
downloaded_size += len(chunk)
# Envía actualización cada 0.5 s máx.
now = time.time()
if now - last_update >= 0.5:
elapsed = now - start_time
speed = downloaded_size / elapsed if elapsed else 0
eta = ((total_size - downloaded_size) / speed
if speed else None)
self.update_progress_callback(
downloaded_size, total_size,
file_id=file_id,
file_path=file_path,
speed=speed,
eta=eta
)
last_update = now
# ─────── Fin de la descarga ───────
elapsed = time.time() - start_time
final_speed = total_size / elapsed if elapsed else 0
# 1· Fuerza la barra al 100 % (sin status)
self.update_progress_callback(
total_size, total_size,
file_id=file_id,
file_path=file_path,
speed=final_speed,
eta=0
)
# 2· Notifica “Completed” (no altera la barra)
self.update_progress_callback(
total_size, total_size,
file_id=file_id,
file_path=file_path,
status="Completed"
)
# Contabiliza y avanza la barra global
self.completed_files += 1
if self.update_global_progress_callback:
self.update_global_progress_callback(
self.completed_files, self.total_files
)
self.log(self.tr("Download successful: {resource_type}, "
"{file_path}",
resource_type=resource_type,
file_path=file_path))
break # Éxito ⇒ sal del bucle
except (requests.exceptions.ChunkedEncodingError,
requests.exceptions.ConnectionError,
requests.exceptions.Timeout) as e:
retries += 1
self.log(self.tr("Error downloading {resource_type}, "
"attempt {retries}/{max_retries}: {error}",
resource_type=resource_type,
retries=retries,
max_retries=max_retries,
error=e))
if retries == max_retries:
self.log(self.tr("Max retries reached. Failed to "
"download {resource_type}: {file_path}",
resource_type=resource_type,
file_path=file_path))
def process_album_page(self, page_url, base_folder, download_images=True, download_videos=True):
try:
if self.cancel_requested:
return
self.log(self.tr("Processing album URL: {page_url}", page_url=page_url))
response = requests.get(page_url, headers=self.headers)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
if not self.direct_download:
folder_name = self.clean_filename(soup.find('h1').text if soup.find('h1') else self.tr("Unknown Album"))
folder_path = self.create_folder(os.path.join(base_folder, folder_name))
else:
folder_path = base_folder # Use the base folder directly
media_urls = []
seen_urls = set()
# --- vídeos ---
if download_videos:
for video in soup.find_all('video'):
source = video.find('source')
if source:
abs_video_src = urljoin(page_url, source['src'])
if abs_video_src in seen_urls: # ya lo tenemos
continue
seen_urls.add(abs_video_src)
video_name = os.path.join(
folder_path,
self.clean_filename(os.path.basename(abs_video_src))
)
media_urls.append(
(abs_video_src, video_name, 'Video')
)
# --- imágenes ---
if download_images:
for div in soup.select('div.img'):
img = div.find('img', attrs={'data-src': True})
if img:
abs_img_src = urljoin(page_url, img['data-src'])
if abs_img_src in seen_urls:
continue
seen_urls.add(abs_img_src)
img_name = os.path.join(
folder_path,
self.clean_filename(os.path.basename(abs_img_src))
)
media_urls.append(
(abs_img_src, img_name, 'Image')
)
self.total_files += len(media_urls)
futures = [self.executor.submit(self.download_file, url, file_path, resource_type, str(uuid.uuid4())) for url, file_path, resource_type in media_urls]
for future in as_completed(futures):
if self.cancel_requested:
self.log(self.tr("Cancelling remaining downloads."))
break
future.result()
self.log(self.tr("Album download complete: {folder_name}", folder_name=folder_name) if not self.direct_download else self.tr("Album download complete"))
if not self.is_profile_download:
self.enable_widgets_callback()
else:
self.log(self.tr("Error accessing page: {page_url}, status code: {status_code}", page_url=page_url, status_code=response.status_code))
if not self.is_profile_download:
self.enable_widgets_callback()
finally:
if not self.is_profile_download:
self.enable_widgets_callback()
self.export_logs()
def process_profile_page(self, url, download_folder, download_images, download_videos):
try:
if self.cancel_requested:
return
self.log(self.tr("Processing profile URL: {url}", url=url))
response = requests.get(url, headers=self.headers)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
username = soup.find('h1', class_='username').text.strip() if soup.find('h1', class_='username') else self.tr("Unknown Profile")
base_folder = self.create_folder(os.path.join(download_folder, self.clean_filename(username)))
album_links = soup.find_all('a', class_='album-link')
for album_link in album_links:
album_href = album_link.get('href')
album_full_url = urljoin(url, album_href)
self.process_album_page(album_full_url, base_folder, download_images, download_videos)
self.log(self.tr("Profile download complete: {username}", username=username))
self.enable_widgets_callback()
else:
self.log(self.tr("Error accessing page: {url}, status code: {status_code}", url=url, status_code=response.status_code))
self.enable_widgets_callback()
finally:
if not self.is_profile_download:
self.enable_widgets_callback()
self.export_logs()
def export_logs(self):
log_folder = "resources/config/logs/"
Path(log_folder).mkdir(parents=True, exist_ok=True)
log_file_path = Path(log_folder) / f"log_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
try:
with open(log_file_path, 'w') as file:
file.write("\n".join(self.log_messages))
self.log(self.tr("Logs exported successfully to {path}", path=log_file_path))
except Exception as e:
self.log(self.tr(f"Failed to export logs: {e}"))
@@ -0,0 +1,112 @@
import requests
from bs4 import BeautifulSoup
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from app import progress_manager
class Jpg5Downloader:
def __init__(self, url, carpeta_destino, progress_manager, log_callback=None, tr=None, update_progress_callback=None, update_global_progress_callback=None, max_workers=3):
self.url = url
self.carpeta_destino = carpeta_destino
self.log_callback = log_callback
self.tr = tr if tr else lambda x: x # Función de traducción por defecto
self.cancel_requested = threading.Event() # Usar un evento para manejar la cancelación
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.max_workers = max_workers
self.progress_manager = progress_manager
def log(self, message):
if self.log_callback:
self.log_callback(message)
else:
print(message)
def request_cancel(self):
self.cancel_requested.set() # Activar el evento de cancelación
self.log(self.tr("Descarga cancelada por el usuario."))
def descargar_imagenes(self):
if not os.path.exists(self.carpeta_destino):
os.makedirs(self.carpeta_destino)
self.log(self.tr(f"Iniciando descarga desde: {self.url}"))
respuesta = requests.get(self.url)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
soup = BeautifulSoup(respuesta.content, 'html.parser')
divs = soup.find_all('div', class_='list-item c8 gutter-margin-right-bottom')
total_divs = len(divs)
self.log(self.tr(f"Total de elementos a procesar: {total_divs}"))
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = []
for i, div in enumerate(divs):
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
enlaces = div.find_all('a', class_='image-container --media')
for enlace in enlaces:
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
futures.append(executor.submit(self.descargar_enlace, enlace, i, total_divs))
for future in futures:
future.result() # Esperar a que todas las descargas terminen
def descargar_enlace(self, enlace, i, total_divs):
try:
media_url = enlace['href']
self.log(self.tr(f"Procesando enlace: {media_url}"))
media_respuesta = requests.get(media_url)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
media_soup = BeautifulSoup(media_respuesta.content, 'html.parser')
header_content = media_soup.find('div', class_='header-content-right')
if header_content:
btn_descarga = header_content.find('a', class_='btn btn-download default')
if btn_descarga and 'href' in btn_descarga.attrs:
descarga_url = btn_descarga['href']
self.log(self.tr(f"Descargando desde: {descarga_url}"))
img_respuesta = requests.get(descarga_url, stream=True)
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
img_nombre = os.path.basename(descarga_url)
img_path = os.path.join(self.carpeta_destino, img_nombre)
total_size = int(img_respuesta.headers.get('content-length', 0))
downloaded_size = 0
with open(img_path, 'wb') as f:
for chunk in img_respuesta.iter_content(chunk_size=1024):
if self.cancel_requested.is_set():
self.log(self.tr("Descarga cancelada por el usuario."))
return
f.write(chunk)
downloaded_size += len(chunk)
if self.update_progress_callback:
self.update_progress_callback(downloaded_size, total_size)
self.log(self.tr(f"Imagen descargada: {img_nombre}"))
if self.update_global_progress_callback:
self.update_global_progress_callback(i + 1, total_divs)
else:
self.log(self.tr("No se encontró el enlace de descarga."))
else:
self.log(self.tr("No se encontró la clase 'header-content-right'."))
except Exception as e:
self.log(self.tr(f"Error al procesar el enlace: {e}"))
@@ -0,0 +1,137 @@
import os
import json
import re
import queue
from pathlib import Path
from bs4 import BeautifulSoup
from urllib.parse import urlparse
import cloudscraper
from concurrent.futures import ThreadPoolExecutor
class SimpCity:
def __init__(self, download_folder, max_workers=5, log_callback=None, enable_widgets_callback=None, update_progress_callback=None, update_global_progress_callback=None, tr=None):
self.download_folder = download_folder
self.max_workers = max_workers
self.descargadas = set()
self.log_callback = log_callback
self.enable_widgets_callback = enable_widgets_callback
self.update_progress_callback = update_progress_callback
self.update_global_progress_callback = update_global_progress_callback
self.cancel_requested = False
self.total_files = 0
self.completed_files = 0
self.download_queue = queue.Queue()
self.scraper = cloudscraper.create_scraper(browser={'browser': 'chrome', 'platform': 'windows', 'mobile': False})
self.tr = tr
# Selectors from original crawler
self.title_selector = "h1[class=p-title-value]"
self.posts_selector = "div[class*=message-main]"
self.post_content_selector = "div[class*=message-userContent]"
self.images_selector = "img[class*=bbImage]"
self.videos_selector = "video source"
self.iframe_selector = "iframe[class=saint-iframe]"
self.attachments_block_selector = "section[class=message-attachments]"
self.attachments_selector = "a"
self.next_page_selector = "a[class*=pageNav-jump--next]"
self.cookies_path = "resources/config/cookies/simpcity.json"
self.set_cookies()
def log(self, message):
if self.log_callback:
self.log_callback(message)
def sanitize_folder_name(self, name):
return re.sub(r'[<>:"/\\|?*]', '_', name)
def set_cookies(self):
if os.path.exists(self.cookies_path):
with open(self.cookies_path, "r", encoding="utf-8") as f:
cookies = json.load(f)
if isinstance(cookies, dict):
cookies = [cookies]
for c in cookies:
if isinstance(c, dict) and "name" in c and "value" in c:
self.scraper.cookies.set(c["name"], c["value"])
def fetch_page(self, url):
try:
response = self.scraper.get(url)
if response.status_code == 200:
return BeautifulSoup(response.content, 'html.parser')
else:
self.log(self.tr(f"Error: {response.status_code} al acceder a {url}"))
return None
except Exception as e:
self.log(self.tr(f"Error al acceder a {url}: {e}"))
return None
def save_file(self, file_url, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
response = self.scraper.get(file_url, stream=True)
if response.status_code == 200:
with open(path, 'wb') as file:
for chunk in response.iter_content(1024):
file.write(chunk)
self.log(self.tr(f"Archivo descargado: {path}"))
else:
self.log(self.tr(f"Error al descargar {file_url}: {response.status_code}"))
def process_post(self, post_content, download_folder):
# Procesar imágenes
images = post_content.select(self.images_selector)
for img in images:
src = img.get('src')
if src:
file_name = os.path.basename(urlparse(src).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(src, file_path)
# Procesar videos
videos = post_content.select(self.videos_selector)
for video in videos:
src = video.get('src')
if src:
file_name = os.path.basename(urlparse(src).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(src, file_path)
# Procesar archivos adjuntos
attachments_block = post_content.select_one(self.attachments_block_selector)
if attachments_block:
attachments = attachments_block.select(self.attachments_selector)
for attachment in attachments:
href = attachment.get('href')
if href:
file_name = os.path.basename(urlparse(href).path)
file_path = os.path.join(download_folder, file_name)
self.save_file(href, file_path)
def process_page(self, url):
soup = self.fetch_page(url)
if not soup:
return
title_element = soup.select_one(self.title_selector)
folder_name = self.sanitize_folder_name(title_element.text.strip()) if title_element else 'SimpCity_Download'
download_folder = os.path.join(self.download_folder, folder_name)
os.makedirs(download_folder, exist_ok=True)
message_inners = soup.select(self.posts_selector)
for post in message_inners:
post_content = post.select_one(self.post_content_selector)
if post_content:
self.process_post(post_content, download_folder)
next_page = soup.select_one(self.next_page_selector)
if next_page:
next_page_url = next_page.get('href')
if next_page_url:
self.process_page(self.base_url + next_page_url)
def download_images_from_simpcity(self, url):
self.log(self.tr(f"Procesando hilo: {url}"))
self.process_page(url)
self.log(self.tr("Descarga completada."))
@@ -0,0 +1,8 @@
from app.ui import ImageDownloaderApp
def main():
app = ImageDownloaderApp()
app.mainloop()
if __name__ == "__main__":
main()
@@ -0,0 +1,10 @@
customtkinter
beautifulsoup4
urllib3
requests
pillow
psutil
markdown2
tkinterweb
cloudscraper
selenium
@@ -0,0 +1,9 @@
{
"max_downloads": 5,
"folder_structure": "default",
"language": "en",
"theme": "Dark",
"max_retries": 4,
"retry_interval": 2.0,
"file_naming_mode": 0
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webapp</title>
<title>Server Manager</title>
</head>
<body>
<div id="app"></div>
+146 -9
View File
@@ -57,7 +57,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -73,6 +72,12 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@mdi/font": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
"integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==",
"license": "Apache-2.0"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -87,7 +92,19 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
@@ -98,7 +115,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
@@ -168,6 +184,12 @@
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
@@ -218,12 +240,42 @@
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"license": "MIT"
},
"node_modules/@vuetify/loader-shared": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vuetify/loader-shared/-/loader-shared-2.1.1.tgz",
"integrity": "sha512-jSZTzTYaoiv8iwonFCVZQ0YYX/M+Uyl4ng+C4egMJT0Hcmh9gIxJL89qfZICDeo3g0IhqrvipW2FFKKRDMtVcA==",
"license": "MIT",
"dependencies": {
"upath": "^2.0.1"
},
"peerDependencies": {
"vue": "^3.0.0",
"vuetify": "^3.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/entities": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
@@ -240,7 +292,6 @@
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -288,7 +339,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -311,6 +361,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -339,7 +395,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
@@ -377,11 +432,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/roboto-fontface": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/roboto-fontface/-/roboto-fontface-0.10.0.tgz",
"integrity": "sha512-OlwfYEgA2RdboZohpldlvJ1xngOins5d7ejqnIBWr9KaMxsnBqotpptRXTyfNRLnFpqzX6sTDt+X+a+6udnU8g==",
"license": "Apache-2.0"
},
"node_modules/rollup": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -432,7 +492,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -445,11 +504,20 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/upath": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
"integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==",
"license": "MIT",
"engines": {
"node": ">=4",
"yarn": "*"
}
},
"node_modules/vite": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -521,6 +589,26 @@
}
}
},
"node_modules/vite-plugin-vuetify": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.1.2.tgz",
"integrity": "sha512-I/wd6QS+DO6lHmuGoi1UTyvvBTQ2KDzQZ9oowJQEJ6OcjWfJnscYXx2ptm6S7fJSASuZT8jGRBL3LV4oS3LpaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vuetify/loader-shared": "^2.1.1",
"debug": "^4.3.3",
"upath": "^2.0.1"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": ">=5",
"vue": "^3.0.0",
"vuetify": "^3.0.0"
}
},
"node_modules/vue": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
@@ -542,6 +630,55 @@
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vuetify": {
"version": "3.11.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.6.tgz",
"integrity": "sha512-vaWvEpDSeldRoib1tCKNVBp60paTD2/n0a0TmrVLF9BN4CJJn6/A4VKG2Sg+DE8Yc+SNOtFtipChxSlQxjcUvw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/johnleider"
},
"peerDependencies": {
"typescript": ">=4.7",
"vite-plugin-vuetify": ">=2.1.0",
"vue": "^3.5.0",
"webpack-plugin-vuetify": ">=3.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vite-plugin-vuetify": {
"optional": true
},
"webpack-plugin-vuetify": {
"optional": true
}
}
},
"node_modules/webfontloader": {
"version": "1.6.28",
"resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz",
"integrity": "sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==",
"license": "Apache-2.0"
}
}
}
+466 -6
View File
@@ -1,15 +1,475 @@
{
"hash": "de5351f0",
"configHash": "ad9d2ba9",
"lockfileHash": "d8cac6d1",
"browserHash": "85657f81",
"hash": "58793c98",
"configHash": "939bcc17",
"lockfileHash": "b10062e0",
"browserHash": "e6aae6df",
"optimized": {
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "936bccf3",
"fileHash": "c9954258",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "e764d986",
"needsInterop": false
},
"vuetify": {
"src": "../../vuetify/lib/framework.js",
"file": "vuetify.js",
"fileHash": "369c86f0",
"needsInterop": false
},
"vuetify/components": {
"src": "../../vuetify/lib/components/index.js",
"file": "vuetify_components.js",
"fileHash": "721553c0",
"needsInterop": false
},
"vuetify/directives": {
"src": "../../vuetify/lib/directives/index.js",
"file": "vuetify_directives.js",
"fileHash": "cf4c59e7",
"needsInterop": false
},
"vuetify/iconsets/mdi": {
"src": "../../vuetify/lib/iconsets/mdi.js",
"file": "vuetify_iconsets_mdi.js",
"fileHash": "590e0963",
"needsInterop": false
},
"vuetify/components/VApp": {
"src": "../../vuetify/lib/components/VApp/index.js",
"file": "vuetify_components_VApp.js",
"fileHash": "f6cb1574",
"needsInterop": false
},
"vuetify/components/VAppBar": {
"src": "../../vuetify/lib/components/VAppBar/index.js",
"file": "vuetify_components_VAppBar.js",
"fileHash": "bd33378b",
"needsInterop": false
},
"vuetify/components/VBtn": {
"src": "../../vuetify/lib/components/VBtn/index.js",
"file": "vuetify_components_VBtn.js",
"fileHash": "b2541520",
"needsInterop": false
},
"vuetify/components/VDivider": {
"src": "../../vuetify/lib/components/VDivider/index.js",
"file": "vuetify_components_VDivider.js",
"fileHash": "1201295e",
"needsInterop": false
},
"vuetify/components/VGrid": {
"src": "../../vuetify/lib/components/VGrid/index.js",
"file": "vuetify_components_VGrid.js",
"fileHash": "dec97038",
"needsInterop": false
},
"vuetify/components/VList": {
"src": "../../vuetify/lib/components/VList/index.js",
"file": "vuetify_components_VList.js",
"fileHash": "92076a71",
"needsInterop": false
},
"vuetify/components/VMain": {
"src": "../../vuetify/lib/components/VMain/index.js",
"file": "vuetify_components_VMain.js",
"fileHash": "aea7930b",
"needsInterop": false
},
"vuetify/components/VNavigationDrawer": {
"src": "../../vuetify/lib/components/VNavigationDrawer/index.js",
"file": "vuetify_components_VNavigationDrawer.js",
"fileHash": "fa7c5e03",
"needsInterop": false
},
"vuetify/components/VAlert": {
"src": "../../vuetify/lib/components/VAlert/index.js",
"file": "vuetify_components_VAlert.js",
"fileHash": "5dc7580a",
"needsInterop": false
},
"vuetify/components/VCard": {
"src": "../../vuetify/lib/components/VCard/index.js",
"file": "vuetify_components_VCard.js",
"fileHash": "90108afc",
"needsInterop": false
},
"vuetify/components/VDialog": {
"src": "../../vuetify/lib/components/VDialog/index.js",
"file": "vuetify_components_VDialog.js",
"fileHash": "e56ad233",
"needsInterop": false
},
"vuetify/components/VIcon": {
"src": "../../vuetify/lib/components/VIcon/index.js",
"file": "vuetify_components_VIcon.js",
"fileHash": "ccd96f39",
"needsInterop": false
},
"vuetify/components/VToolbar": {
"src": "../../vuetify/lib/components/VToolbar/index.js",
"file": "vuetify_components_VToolbar.js",
"fileHash": "f4e18631",
"needsInterop": false
},
"vuetify/components/VBreadcrumbs": {
"src": "../../vuetify/lib/components/VBreadcrumbs/index.js",
"file": "vuetify_components_VBreadcrumbs.js",
"fileHash": "b9da5690",
"needsInterop": false
},
"vuetify/components/VDataTable": {
"src": "../../vuetify/lib/components/VDataTable/index.js",
"file": "vuetify_components_VDataTable.js",
"fileHash": "d06d0545",
"needsInterop": false
},
"vuetify/components/VFileInput": {
"src": "../../vuetify/lib/components/VFileInput/index.js",
"file": "vuetify_components_VFileInput.js",
"fileHash": "9b6d1640",
"needsInterop": false
},
"vuetify/components/VMenu": {
"src": "../../vuetify/lib/components/VMenu/index.js",
"file": "vuetify_components_VMenu.js",
"fileHash": "6401d9c7",
"needsInterop": false
},
"vuetify/components/VProgressLinear": {
"src": "../../vuetify/lib/components/VProgressLinear/index.js",
"file": "vuetify_components_VProgressLinear.js",
"fileHash": "f6998592",
"needsInterop": false
},
"vuetify/components/VSelect": {
"src": "../../vuetify/lib/components/VSelect/index.js",
"file": "vuetify_components_VSelect.js",
"fileHash": "fff4e409",
"needsInterop": false
},
"vuetify/components/VSnackbar": {
"src": "../../vuetify/lib/components/VSnackbar/index.js",
"file": "vuetify_components_VSnackbar.js",
"fileHash": "e8e1d7d3",
"needsInterop": false
},
"vuetify/components/VCheckbox": {
"src": "../../vuetify/lib/components/VCheckbox/index.js",
"file": "vuetify_components_VCheckbox.js",
"fileHash": "6c2a3d1f",
"needsInterop": false
},
"vuetify/components/VChip": {
"src": "../../vuetify/lib/components/VChip/index.js",
"file": "vuetify_components_VChip.js",
"fileHash": "149d1552",
"needsInterop": false
},
"vuetify/components/VExpansionPanel": {
"src": "../../vuetify/lib/components/VExpansionPanel/index.js",
"file": "vuetify_components_VExpansionPanel.js",
"fileHash": "e8175e4b",
"needsInterop": false
},
"vuetify/components/VSlider": {
"src": "../../vuetify/lib/components/VSlider/index.js",
"file": "vuetify_components_VSlider.js",
"fileHash": "70d54e33",
"needsInterop": false
},
"vuetify/components/transitions": {
"src": "../../vuetify/lib/components/transitions/index.js",
"file": "vuetify_components_transitions.js",
"fileHash": "1ae42e15",
"needsInterop": false
},
"vuetify/components/VImg": {
"src": "../../vuetify/lib/components/VImg/index.js",
"file": "vuetify_components_VImg.js",
"fileHash": "55902c55",
"needsInterop": false
},
"vuetify/components/VProgressCircular": {
"src": "../../vuetify/lib/components/VProgressCircular/index.js",
"file": "vuetify_components_VProgressCircular.js",
"fileHash": "4ac970e2",
"needsInterop": false
},
"vuetify/components/VSheet": {
"src": "../../vuetify/lib/components/VSheet/index.js",
"file": "vuetify_components_VSheet.js",
"fileHash": "60500776",
"needsInterop": false
},
"vuetify/components/VForm": {
"src": "../../vuetify/lib/components/VForm/index.js",
"file": "vuetify_components_VForm.js",
"fileHash": "6098d2ad",
"needsInterop": false
},
"vuetify/components/VTextField": {
"src": "../../vuetify/lib/components/VTextField/index.js",
"file": "vuetify_components_VTextField.js",
"fileHash": "6c3b6c9f",
"needsInterop": false
},
"vuetify/components/VBtnToggle": {
"src": "../../vuetify/lib/components/VBtnToggle/index.js",
"file": "vuetify_components_VBtnToggle.js",
"fileHash": "92b719ca",
"needsInterop": false
},
"vuetify/components/VTextarea": {
"src": "../../vuetify/lib/components/VTextarea/index.js",
"file": "vuetify_components_VTextarea.js",
"fileHash": "c4710e99",
"needsInterop": false
}
},
"chunks": {}
"chunks": {
"chunk-KPWCGWEX": {
"file": "chunk-KPWCGWEX.js"
},
"chunk-5LZQZ6A5": {
"file": "chunk-5LZQZ6A5.js"
},
"chunk-F6LZGKE2": {
"file": "chunk-F6LZGKE2.js"
},
"chunk-4MMPKXON": {
"file": "chunk-4MMPKXON.js"
},
"chunk-LHMOJDYM": {
"file": "chunk-LHMOJDYM.js"
},
"chunk-TSTWIJCO": {
"file": "chunk-TSTWIJCO.js"
},
"chunk-YDVSSFCH": {
"file": "chunk-YDVSSFCH.js"
},
"chunk-RNFCLGZV": {
"file": "chunk-RNFCLGZV.js"
},
"chunk-WNCBJDZZ": {
"file": "chunk-WNCBJDZZ.js"
},
"chunk-34XWDQGB": {
"file": "chunk-34XWDQGB.js"
},
"chunk-RH43OVWP": {
"file": "chunk-RH43OVWP.js"
},
"chunk-PEUMXYT2": {
"file": "chunk-PEUMXYT2.js"
},
"chunk-4HCXUCLO": {
"file": "chunk-4HCXUCLO.js"
},
"chunk-ERI3RTQS": {
"file": "chunk-ERI3RTQS.js"
},
"chunk-VSEC5QGQ": {
"file": "chunk-VSEC5QGQ.js"
},
"chunk-BYBKODOB": {
"file": "chunk-BYBKODOB.js"
},
"chunk-SHBPUR7N": {
"file": "chunk-SHBPUR7N.js"
},
"chunk-HGIC64KW": {
"file": "chunk-HGIC64KW.js"
},
"chunk-BXEVD6W6": {
"file": "chunk-BXEVD6W6.js"
},
"chunk-LPZZJ62X": {
"file": "chunk-LPZZJ62X.js"
},
"chunk-UIXKNP5E": {
"file": "chunk-UIXKNP5E.js"
},
"chunk-DRSCVHAQ": {
"file": "chunk-DRSCVHAQ.js"
},
"chunk-TSYQL5U3": {
"file": "chunk-TSYQL5U3.js"
},
"chunk-MVBFAXEB": {
"file": "chunk-MVBFAXEB.js"
},
"chunk-QC7VWQER": {
"file": "chunk-QC7VWQER.js"
},
"chunk-NURQ3473": {
"file": "chunk-NURQ3473.js"
},
"chunk-ET5GCC5W": {
"file": "chunk-ET5GCC5W.js"
},
"chunk-W6DJL42A": {
"file": "chunk-W6DJL42A.js"
},
"chunk-HV7P7R7J": {
"file": "chunk-HV7P7R7J.js"
},
"chunk-MPVSDM4V": {
"file": "chunk-MPVSDM4V.js"
},
"chunk-HOEZWRBG": {
"file": "chunk-HOEZWRBG.js"
},
"chunk-Z3677HMS": {
"file": "chunk-Z3677HMS.js"
},
"chunk-ADXDXPPR": {
"file": "chunk-ADXDXPPR.js"
},
"chunk-7SKEB5LN": {
"file": "chunk-7SKEB5LN.js"
},
"chunk-PCKQGDLK": {
"file": "chunk-PCKQGDLK.js"
},
"chunk-6CRS5HI4": {
"file": "chunk-6CRS5HI4.js"
},
"chunk-X7YBE6PF": {
"file": "chunk-X7YBE6PF.js"
},
"chunk-YJD2PNP6": {
"file": "chunk-YJD2PNP6.js"
},
"chunk-I4SZJCEY": {
"file": "chunk-I4SZJCEY.js"
},
"chunk-OI3EYDCB": {
"file": "chunk-OI3EYDCB.js"
},
"chunk-IZQZFEAL": {
"file": "chunk-IZQZFEAL.js"
},
"chunk-J7Q2NMV7": {
"file": "chunk-J7Q2NMV7.js"
},
"chunk-4VABFUPG": {
"file": "chunk-4VABFUPG.js"
},
"chunk-GAYXO4FM": {
"file": "chunk-GAYXO4FM.js"
},
"chunk-B4JFJDWM": {
"file": "chunk-B4JFJDWM.js"
},
"chunk-U2DSRSME": {
"file": "chunk-U2DSRSME.js"
},
"chunk-FVDWV4NO": {
"file": "chunk-FVDWV4NO.js"
},
"chunk-AJ7MUB7M": {
"file": "chunk-AJ7MUB7M.js"
},
"chunk-KTTVT6V2": {
"file": "chunk-KTTVT6V2.js"
},
"chunk-TM3MIDYC": {
"file": "chunk-TM3MIDYC.js"
},
"chunk-CMZ4UYUX": {
"file": "chunk-CMZ4UYUX.js"
},
"chunk-PIT7IT55": {
"file": "chunk-PIT7IT55.js"
},
"chunk-VH7QIDM4": {
"file": "chunk-VH7QIDM4.js"
},
"chunk-AQK3CYVW": {
"file": "chunk-AQK3CYVW.js"
},
"chunk-2HZIACSB": {
"file": "chunk-2HZIACSB.js"
},
"chunk-WGYWYQOA": {
"file": "chunk-WGYWYQOA.js"
},
"chunk-NJV3GUE4": {
"file": "chunk-NJV3GUE4.js"
},
"chunk-6VKWM6MN": {
"file": "chunk-6VKWM6MN.js"
},
"chunk-TS6Y6OVT": {
"file": "chunk-TS6Y6OVT.js"
},
"chunk-YGX2Z63P": {
"file": "chunk-YGX2Z63P.js"
},
"chunk-5BPWKCNA": {
"file": "chunk-5BPWKCNA.js"
},
"chunk-BVUPSGRF": {
"file": "chunk-BVUPSGRF.js"
},
"chunk-573D4TYC": {
"file": "chunk-573D4TYC.js"
},
"chunk-GUNW7S2L": {
"file": "chunk-GUNW7S2L.js"
},
"chunk-5ONGSJQ2": {
"file": "chunk-5ONGSJQ2.js"
},
"chunk-CAGZXMOS": {
"file": "chunk-CAGZXMOS.js"
},
"chunk-6EZKHCRW": {
"file": "chunk-6EZKHCRW.js"
},
"chunk-N4LJHDYD": {
"file": "chunk-N4LJHDYD.js"
},
"chunk-OJDZBU5D": {
"file": "chunk-OJDZBU5D.js"
},
"chunk-IYFY4IHW": {
"file": "chunk-IYFY4IHW.js"
},
"chunk-KQXZAEJP": {
"file": "chunk-KQXZAEJP.js"
},
"chunk-XDSAO7OT": {
"file": "chunk-XDSAO7OT.js"
},
"chunk-ENTLCOCZ": {
"file": "chunk-ENTLCOCZ.js"
},
"chunk-KA77II4V": {
"file": "chunk-KA77II4V.js"
},
"chunk-WXMECWZN": {
"file": "chunk-WXMECWZN.js"
},
"chunk-BHV5Z34M": {
"file": "chunk-BHV5Z34M.js"
},
"chunk-3DSR7NCB": {
"file": "chunk-3DSR7NCB.js"
},
"chunk-LZ5T2ERG": {
"file": "chunk-LZ5T2ERG.js"
},
"chunk-2MKFL3BX": {
"file": "chunk-2MKFL3BX.js"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import {
propsFactory
} from "./chunk-LZ5T2ERG.js";
// node_modules/vuetify/lib/composables/tag.js
var makeTagProps = propsFactory({
tag: {
type: [String, Object, Function],
default: "div"
}
}, "tag");
export {
makeTagProps
};
//# sourceMappingURL=chunk-2HZIACSB.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../vuetify/src/composables/tag.ts"],
"sourcesContent": ["// Utilities\nimport { propsFactory } from '@/util'\n\n// Types\nimport type { PropType } from 'vue'\nimport type { JSXComponent } from '@/util'\n\n// Types\nexport interface TagProps {\n tag: string | JSXComponent\n}\n\n// Composables\nexport const makeTagProps = propsFactory({\n tag: {\n type: [String, Object, Function] as PropType<string | JSXComponent>,\n default: 'div',\n },\n}, 'tag')\n"],
"mappings": ";;;;;AAaO,IAAMA,eAAeC,aAAa;EACvCC,KAAK;IACHC,MAAM,CAACC,QAAQC,QAAQC,QAAQ;IAC/BC,SAAS;EACX;AACF,GAAG,KAAK;",
"names": ["makeTagProps", "propsFactory", "tag", "type", "String", "Object", "Function", "default"]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+137
View File
@@ -0,0 +1,137 @@
import {
VDialogTransition
} from "./chunk-B4JFJDWM.js";
import {
VDefaultsProvider
} from "./chunk-VH7QIDM4.js";
import {
VOverlay,
makeVOverlayProps
} from "./chunk-TS6Y6OVT.js";
import {
useScopeId
} from "./chunk-BVUPSGRF.js";
import {
forwardRefs
} from "./chunk-GUNW7S2L.js";
import {
useProxiedModel
} from "./chunk-KA77II4V.js";
import {
genericComponent,
omit,
propsFactory,
useRender
} from "./chunk-LZ5T2ERG.js";
import {
createVNode,
mergeProps,
nextTick,
ref,
watch
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/components/VDialog/VDialog.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/components/VDialog/VDialog.css";
var makeVDialogProps = propsFactory({
fullscreen: Boolean,
scrollable: Boolean,
...omit(makeVOverlayProps({
captureFocus: true,
origin: "center center",
scrollStrategy: "block",
transition: {
component: VDialogTransition
},
zIndex: 2400,
retainFocus: true
}), ["disableInitialFocus"])
}, "VDialog");
var VDialog = genericComponent()({
name: "VDialog",
props: makeVDialogProps(),
emits: {
"update:modelValue": (value) => true,
afterEnter: () => true,
afterLeave: () => true
},
setup(props, _ref) {
let {
emit,
slots
} = _ref;
const isActive = useProxiedModel(props, "modelValue");
const {
scopeId
} = useScopeId();
const overlay = ref();
function onAfterEnter() {
emit("afterEnter");
if ((props.scrim || props.retainFocus) && overlay.value?.contentEl && !overlay.value.contentEl.contains(document.activeElement)) {
overlay.value.contentEl.focus({
preventScroll: true
});
}
}
function onAfterLeave() {
emit("afterLeave");
}
watch(isActive, async (val) => {
if (!val) {
await nextTick();
overlay.value.activatorEl?.focus({
preventScroll: true
});
}
});
useRender(() => {
const overlayProps = VOverlay.filterProps(props);
const activatorProps = mergeProps({
"aria-haspopup": "dialog"
}, props.activatorProps);
const contentProps = mergeProps({
tabindex: -1
}, props.contentProps);
return createVNode(VOverlay, mergeProps({
"ref": overlay,
"class": ["v-dialog", {
"v-dialog--fullscreen": props.fullscreen,
"v-dialog--scrollable": props.scrollable
}, props.class],
"style": props.style
}, overlayProps, {
"modelValue": isActive.value,
"onUpdate:modelValue": ($event) => isActive.value = $event,
"aria-modal": "true",
"activatorProps": activatorProps,
"contentProps": contentProps,
"height": !props.fullscreen ? props.height : void 0,
"width": !props.fullscreen ? props.width : void 0,
"maxHeight": !props.fullscreen ? props.maxHeight : void 0,
"maxWidth": !props.fullscreen ? props.maxWidth : void 0,
"role": "dialog",
"onAfterEnter": onAfterEnter,
"onAfterLeave": onAfterLeave
}, scopeId), {
activator: slots.activator,
default: function() {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
return createVNode(VDefaultsProvider, {
"root": "VDialog"
}, {
default: () => [slots.default?.(...args)]
});
}
});
});
return forwardRefs({}, overlay);
}
});
export {
makeVDialogProps,
VDialog
};
//# sourceMappingURL=chunk-34XWDQGB.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../vuetify/src/components/VDialog/VDialog.tsx"],
"sourcesContent": ["// Styles\nimport './VDialog.sass'\n\n// Components\nimport { VDialogTransition } from '@/components/transitions'\nimport { VDefaultsProvider } from '@/components/VDefaultsProvider'\nimport { VOverlay } from '@/components/VOverlay'\nimport { makeVOverlayProps } from '@/components/VOverlay/VOverlay'\n\n// Composables\nimport { forwardRefs } from '@/composables/forwardRefs'\nimport { useProxiedModel } from '@/composables/proxiedModel'\nimport { useScopeId } from '@/composables/scopeId'\n\n// Utilities\nimport { mergeProps, nextTick, ref, watch } from 'vue'\nimport { genericComponent, omit, propsFactory, useRender } from '@/util'\n\n// Types\nimport type { OverlaySlots } from '@/components/VOverlay/VOverlay'\n\nexport const makeVDialogProps = propsFactory({\n fullscreen: Boolean,\n scrollable: Boolean,\n\n ...omit(makeVOverlayProps({\n captureFocus: true,\n origin: 'center center' as const,\n scrollStrategy: 'block' as const,\n transition: { component: VDialogTransition },\n zIndex: 2400,\n retainFocus: true,\n }), ['disableInitialFocus']),\n}, 'VDialog')\n\nexport const VDialog = genericComponent<OverlaySlots>()({\n name: 'VDialog',\n\n props: makeVDialogProps(),\n\n emits: {\n 'update:modelValue': (value: boolean) => true,\n afterEnter: () => true,\n afterLeave: () => true,\n },\n\n setup (props, { emit, slots }) {\n const isActive = useProxiedModel(props, 'modelValue')\n const { scopeId } = useScopeId()\n\n const overlay = ref<VOverlay>()\n\n function onAfterEnter () {\n emit('afterEnter')\n if (\n (props.scrim || props.retainFocus) &&\n overlay.value?.contentEl &&\n !overlay.value.contentEl.contains(document.activeElement)\n ) {\n overlay.value.contentEl.focus({ preventScroll: true })\n }\n }\n\n function onAfterLeave () {\n emit('afterLeave')\n }\n\n watch(isActive, async val => {\n if (!val) {\n await nextTick()\n overlay.value!.activatorEl?.focus({ preventScroll: true })\n }\n })\n\n useRender(() => {\n const overlayProps = VOverlay.filterProps(props)\n const activatorProps = mergeProps({\n 'aria-haspopup': 'dialog',\n }, props.activatorProps)\n const contentProps = mergeProps({\n tabindex: -1,\n }, props.contentProps)\n\n return (\n <VOverlay\n ref={ overlay }\n class={[\n 'v-dialog',\n {\n 'v-dialog--fullscreen': props.fullscreen,\n 'v-dialog--scrollable': props.scrollable,\n },\n props.class,\n ]}\n style={ props.style }\n { ...overlayProps }\n v-model={ isActive.value }\n aria-modal=\"true\"\n activatorProps={ activatorProps }\n contentProps={ contentProps }\n height={ !props.fullscreen ? props.height : undefined }\n width={ !props.fullscreen ? props.width : undefined }\n maxHeight={ !props.fullscreen ? props.maxHeight : undefined }\n maxWidth={ !props.fullscreen ? props.maxWidth : undefined }\n role=\"dialog\"\n onAfterEnter={ onAfterEnter }\n onAfterLeave={ onAfterLeave }\n { ...scopeId }\n >\n {{\n activator: slots.activator,\n default: (...args) => (\n <VDefaultsProvider root=\"VDialog\">\n { slots.default?.(...args) }\n </VDefaultsProvider>\n ),\n }}\n </VOverlay>\n )\n })\n\n return forwardRefs({}, overlay)\n },\n})\n\nexport type VDialog = InstanceType<typeof VDialog>\n"],
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AACA,OAAA;AAoBO,IAAMA,mBAAmBC,aAAa;EAC3CC,YAAYC;EACZC,YAAYD;EAEZ,GAAGE,KAAKC,kBAAkB;IACxBC,cAAc;IACdC,QAAQ;IACRC,gBAAgB;IAChBC,YAAY;MAAEC,WAAWC;IAAkB;IAC3CC,QAAQ;IACRC,aAAa;EACf,CAAC,GAAG,CAAC,qBAAqB,CAAC;AAC7B,GAAG,SAAS;AAEL,IAAMC,UAAUC,iBAA+B,EAAE;EACtDC,MAAM;EAENC,OAAOlB,iBAAiB;EAExBmB,OAAO;IACL,qBAAsBC,WAAmB;IACzCC,YAAYA,MAAM;IAClBC,YAAYA,MAAM;EACpB;EAEAC,MAAOL,OAAKM,MAAmB;AAAA,QAAjB;MAAEC;MAAMC;IAAM,IAACF;AAC3B,UAAMG,WAAWC,gBAAgBV,OAAO,YAAY;AACpD,UAAM;MAAEW;IAAQ,IAAIC,WAAW;AAE/B,UAAMC,UAAUC,IAAc;AAE9B,aAASC,eAAgB;AACvBR,WAAK,YAAY;AACjB,WACGP,MAAMgB,SAAShB,MAAMJ,gBACtBiB,QAAQX,OAAOe,aACf,CAACJ,QAAQX,MAAMe,UAAUC,SAASC,SAASC,aAAa,GACxD;AACAP,gBAAQX,MAAMe,UAAUI,MAAM;UAAEC,eAAe;QAAK,CAAC;MACvD;IACF;AAEA,aAASC,eAAgB;AACvBhB,WAAK,YAAY;IACnB;AAEAiB,UAAMf,UAAU,OAAMgB,QAAO;AAC3B,UAAI,CAACA,KAAK;AACR,cAAMC,SAAS;AACfb,gBAAQX,MAAOyB,aAAaN,MAAM;UAAEC,eAAe;QAAK,CAAC;MAC3D;IACF,CAAC;AAEDM,cAAU,MAAM;AACd,YAAMC,eAAeC,SAASC,YAAY/B,KAAK;AAC/C,YAAMgC,iBAAiBC,WAAW;QAChC,iBAAiB;MACnB,GAAGjC,MAAMgC,cAAc;AACvB,YAAME,eAAeD,WAAW;QAC9BE,UAAU;MACZ,GAAGnC,MAAMkC,YAAY;AAErB,aAAAE,YAAAN,UAAAO,WAAA;QAAA,OAEUxB;QAAO,SACN,CACL,YACA;UACE,wBAAwBb,MAAMhB;UAC9B,wBAAwBgB,MAAMd;QAChC,GACAc,MAAMsC,KAAK;QACZ,SACOtC,MAAMuC;MAAK,GACdV,cAAY;QAAA,cACPpB,SAASP;QAAK,uBAAAsC,YAAd/B,SAASP,QAAKsC;QAAA,cAAA;QAAA,kBAEPR;QAAc,gBAChBE;QAAY,UAClB,CAAClC,MAAMhB,aAAagB,MAAMyC,SAASC;QAAS,SAC7C,CAAC1C,MAAMhB,aAAagB,MAAM2C,QAAQD;QAAS,aACvC,CAAC1C,MAAMhB,aAAagB,MAAM4C,YAAYF;QAAS,YAChD,CAAC1C,MAAMhB,aAAagB,MAAM6C,WAAWH;QAAS,QAAA;QAAA,gBAE1C3B;QAAY,gBACZQ;MAAY,GACtBZ,OAAO,GAAA;QAGVmC,WAAWtC,MAAMsC;QACjBC,SAAS,WAAA;AAAA,mBAAAC,OAAAC,UAAAC,QAAIC,OAAI,IAAAC,MAAAJ,IAAA,GAAAK,OAAA,GAAAA,OAAAL,MAAAK,QAAA;AAAJF,iBAAIE,IAAA,IAAAJ,UAAAI,IAAA;UAAA;AAAA,iBAAAjB,YAAAkB,mBAAA;YAAA,QAAA;UAAA,GAAA;YAAAP,SAAAA,MAAA,CAEXvC,MAAMuC,UAAU,GAAGI,IAAI,CAAC;UAAA,CAAA;QAAA;MAE7B,CAAA;IAIT,CAAC;AAED,WAAOI,YAAY,CAAC,GAAG1C,OAAO;EAChC;AACF,CAAC;",
"names": ["makeVDialogProps", "propsFactory", "fullscreen", "Boolean", "scrollable", "omit", "makeVOverlayProps", "captureFocus", "origin", "scrollStrategy", "transition", "component", "VDialogTransition", "zIndex", "retainFocus", "VDialog", "genericComponent", "name", "props", "emits", "value", "afterEnter", "afterLeave", "setup", "_ref", "emit", "slots", "isActive", "useProxiedModel", "scopeId", "useScopeId", "overlay", "ref", "onAfterEnter", "scrim", "contentEl", "contains", "document", "activeElement", "focus", "preventScroll", "onAfterLeave", "watch", "val", "nextTick", "activatorEl", "useRender", "overlayProps", "VOverlay", "filterProps", "activatorProps", "mergeProps", "contentProps", "tabindex", "_createVNode", "_mergeProps", "class", "style", "$event", "height", "undefined", "width", "maxHeight", "maxWidth", "activator", "default", "_len", "arguments", "length", "args", "Array", "_key", "VDefaultsProvider", "forwardRefs"]
}
+427
View File
@@ -0,0 +1,427 @@
import {
IN_BROWSER,
RGBtoHex,
SUPPORTS_MATCH_MEDIA,
consoleWarn,
createRange,
darken,
deprecate,
getCurrentInstance,
getForeground,
getLuma,
lighten,
mergeDeep,
parseColor,
propsFactory
} from "./chunk-LZ5T2ERG.js";
import {
computed,
getCurrentScope,
inject,
onScopeDispose,
provide,
ref,
shallowRef,
toRef,
watch,
watchEffect
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/composables/theme.js
var ThemeSymbol = /* @__PURE__ */ Symbol.for("vuetify:theme");
var makeThemeProps = propsFactory({
theme: String
}, "theme");
function genDefaults() {
return {
defaultTheme: "light",
prefix: "v-",
variations: {
colors: [],
lighten: 0,
darken: 0
},
themes: {
light: {
dark: false,
colors: {
background: "#FFFFFF",
surface: "#FFFFFF",
"surface-bright": "#FFFFFF",
"surface-light": "#EEEEEE",
"surface-variant": "#424242",
"on-surface-variant": "#EEEEEE",
primary: "#1867C0",
"primary-darken-1": "#1F5592",
secondary: "#48A9A6",
"secondary-darken-1": "#018786",
error: "#B00020",
info: "#2196F3",
success: "#4CAF50",
warning: "#FB8C00"
},
variables: {
"border-color": "#000000",
"border-opacity": 0.12,
"high-emphasis-opacity": 0.87,
"medium-emphasis-opacity": 0.6,
"disabled-opacity": 0.38,
"idle-opacity": 0.04,
"hover-opacity": 0.04,
"focus-opacity": 0.12,
"selected-opacity": 0.08,
"activated-opacity": 0.12,
"pressed-opacity": 0.12,
"dragged-opacity": 0.08,
"theme-kbd": "#EEEEEE",
"theme-on-kbd": "#000000",
"theme-code": "#F5F5F5",
"theme-on-code": "#000000"
}
},
dark: {
dark: true,
colors: {
background: "#121212",
surface: "#212121",
"surface-bright": "#ccbfd6",
"surface-light": "#424242",
"surface-variant": "#c8c8c8",
"on-surface-variant": "#000000",
primary: "#2196F3",
"primary-darken-1": "#277CC1",
secondary: "#54B6B2",
"secondary-darken-1": "#48A9A6",
error: "#CF6679",
info: "#2196F3",
success: "#4CAF50",
warning: "#FB8C00"
},
variables: {
"border-color": "#FFFFFF",
"border-opacity": 0.12,
"high-emphasis-opacity": 1,
"medium-emphasis-opacity": 0.7,
"disabled-opacity": 0.5,
"idle-opacity": 0.1,
"hover-opacity": 0.04,
"focus-opacity": 0.12,
"selected-opacity": 0.08,
"activated-opacity": 0.12,
"pressed-opacity": 0.16,
"dragged-opacity": 0.08,
"theme-kbd": "#424242",
"theme-on-kbd": "#FFFFFF",
"theme-code": "#343434",
"theme-on-code": "#CCCCCC"
}
}
},
stylesheetId: "vuetify-theme-stylesheet",
scoped: false,
unimportant: false,
utilities: true
};
}
function parseThemeOptions() {
let options = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : genDefaults();
const defaults = genDefaults();
if (!options) return {
...defaults,
isDisabled: true
};
const themes = {};
for (const [key, theme] of Object.entries(options.themes ?? {})) {
const defaultTheme = theme.dark || key === "dark" ? defaults.themes?.dark : defaults.themes?.light;
themes[key] = mergeDeep(defaultTheme, theme);
}
return mergeDeep(defaults, {
...options,
themes
});
}
function createCssClass(lines, selector, content, scope) {
lines.push(`${getScopedSelector(selector, scope)} {
`, ...content.map((line) => ` ${line};
`), "}\n");
}
function genCssVariables(theme, prefix) {
const lightOverlay = theme.dark ? 2 : 1;
const darkOverlay = theme.dark ? 1 : 2;
const variables = [];
for (const [key, value] of Object.entries(theme.colors)) {
const rgb = parseColor(value);
variables.push(`--${prefix}theme-${key}: ${rgb.r},${rgb.g},${rgb.b}`);
if (!key.startsWith("on-")) {
variables.push(`--${prefix}theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`);
}
}
for (const [key, value] of Object.entries(theme.variables)) {
const color = typeof value === "string" && value.startsWith("#") ? parseColor(value) : void 0;
const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : void 0;
variables.push(`--${prefix}${key}: ${rgb ?? value}`);
}
return variables;
}
function genVariation(name, color, variations) {
const object = {};
if (variations) {
for (const variation of ["lighten", "darken"]) {
const fn = variation === "lighten" ? lighten : darken;
for (const amount of createRange(variations[variation], 1)) {
object[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount));
}
}
}
return object;
}
function genVariations(colors, variations) {
if (!variations) return {};
let variationColors = {};
for (const name of variations.colors) {
const color = colors[name];
if (!color) continue;
variationColors = {
...variationColors,
...genVariation(name, color, variations)
};
}
return variationColors;
}
function genOnColors(colors) {
const onColors = {};
for (const color of Object.keys(colors)) {
if (color.startsWith("on-") || colors[`on-${color}`]) continue;
const onColor = `on-${color}`;
const colorVal = parseColor(colors[color]);
onColors[onColor] = getForeground(colorVal);
}
return onColors;
}
function getScopedSelector(selector, scope) {
if (!scope) return selector;
const scopeSelector = `:where(${scope})`;
return selector === ":root" ? scopeSelector : `${scopeSelector} ${selector}`;
}
function upsertStyles(id, cspNonce, styles) {
const styleEl = getOrCreateStyleElement(id, cspNonce);
if (!styleEl) return;
styleEl.innerHTML = styles;
}
function getOrCreateStyleElement(id, cspNonce) {
if (!IN_BROWSER) return null;
let style = document.getElementById(id);
if (!style) {
style = document.createElement("style");
style.id = id;
style.type = "text/css";
if (cspNonce) style.setAttribute("nonce", cspNonce);
document.head.appendChild(style);
}
return style;
}
function createTheme(options) {
const parsedOptions = parseThemeOptions(options);
const _name = shallowRef(parsedOptions.defaultTheme);
const themes = ref(parsedOptions.themes);
const systemName = shallowRef("light");
const name = computed({
get() {
return _name.value === "system" ? systemName.value : _name.value;
},
set(val) {
_name.value = val;
}
});
const computedThemes = computed(() => {
const acc = {};
for (const [name2, original] of Object.entries(themes.value)) {
const colors = {
...original.colors,
...genVariations(original.colors, parsedOptions.variations)
};
acc[name2] = {
...original,
colors: {
...colors,
...genOnColors(colors)
}
};
}
return acc;
});
const current = toRef(() => computedThemes.value[name.value]);
const isSystem = toRef(() => _name.value === "system");
const styles = computed(() => {
const lines = [];
const important = parsedOptions.unimportant ? "" : " !important";
const scoped = parsedOptions.scoped ? parsedOptions.prefix : "";
if (current.value?.dark) {
createCssClass(lines, ":root", ["color-scheme: dark"], parsedOptions.scope);
}
createCssClass(lines, ":root", genCssVariables(current.value, parsedOptions.prefix), parsedOptions.scope);
for (const [themeName, theme] of Object.entries(computedThemes.value)) {
createCssClass(lines, `.${parsedOptions.prefix}theme--${themeName}`, [`color-scheme: ${theme.dark ? "dark" : "normal"}`, ...genCssVariables(theme, parsedOptions.prefix)], parsedOptions.scope);
}
if (parsedOptions.utilities) {
const bgLines = [];
const fgLines = [];
const colors = new Set(Object.values(computedThemes.value).flatMap((theme) => Object.keys(theme.colors)));
for (const key of colors) {
if (key.startsWith("on-")) {
createCssClass(fgLines, `.${key}`, [`color: rgb(var(--${parsedOptions.prefix}theme-${key}))${important}`], parsedOptions.scope);
} else {
createCssClass(bgLines, `.${scoped}bg-${key}`, [`--${parsedOptions.prefix}theme-overlay-multiplier: var(--${parsedOptions.prefix}theme-${key}-overlay-multiplier)`, `background-color: rgb(var(--${parsedOptions.prefix}theme-${key}))${important}`, `color: rgb(var(--${parsedOptions.prefix}theme-on-${key}))${important}`], parsedOptions.scope);
createCssClass(fgLines, `.${scoped}text-${key}`, [`color: rgb(var(--${parsedOptions.prefix}theme-${key}))${important}`], parsedOptions.scope);
createCssClass(fgLines, `.${scoped}border-${key}`, [`--${parsedOptions.prefix}border-color: var(--${parsedOptions.prefix}theme-${key})`], parsedOptions.scope);
}
}
if (parsedOptions.layers) {
lines.push("@layer background {\n", ...bgLines.map((v) => ` ${v}`), "}\n", "@layer foreground {\n", ...fgLines.map((v) => ` ${v}`), "}\n");
} else {
lines.push(...bgLines, ...fgLines);
}
}
let final = lines.map((str, i) => i === 0 ? str : ` ${str}`).join("");
if (parsedOptions.layers) {
final = "@layer vuetify.theme {\n" + lines.map((v) => ` ${v}`).join("") + "\n}";
}
return final;
});
const themeClasses = toRef(() => parsedOptions.isDisabled ? void 0 : `${parsedOptions.prefix}theme--${name.value}`);
const themeNames = toRef(() => Object.keys(computedThemes.value));
if (SUPPORTS_MATCH_MEDIA) {
let updateSystemName = function() {
systemName.value = media.matches ? "dark" : "light";
};
const media = window.matchMedia("(prefers-color-scheme: dark)");
updateSystemName();
media.addEventListener("change", updateSystemName, {
passive: true
});
if (getCurrentScope()) {
onScopeDispose(() => {
media.removeEventListener("change", updateSystemName);
});
}
}
function install(app) {
if (parsedOptions.isDisabled) return;
const head = app._context.provides.usehead;
if (head) {
let getHead = function() {
return {
style: [{
textContent: styles.value,
id: parsedOptions.stylesheetId,
nonce: parsedOptions.cspNonce || false
}]
};
};
if (head.push) {
const entry = head.push(getHead);
if (IN_BROWSER) {
watch(styles, () => {
entry.patch(getHead);
});
}
} else {
if (IN_BROWSER) {
head.addHeadObjs(toRef(getHead));
watchEffect(() => head.updateDOM());
} else {
head.addHeadObjs(getHead());
}
}
} else {
let updateStyles = function() {
upsertStyles(parsedOptions.stylesheetId, parsedOptions.cspNonce, styles.value);
};
if (IN_BROWSER) {
watch(styles, updateStyles, {
immediate: true
});
} else {
updateStyles();
}
}
}
function change(themeName) {
if (themeName !== "system" && !themeNames.value.includes(themeName)) {
consoleWarn(`Theme "${themeName}" not found on the Vuetify theme instance`);
return;
}
name.value = themeName;
}
function cycle() {
let themeArray = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : themeNames.value;
const currentIndex = themeArray.indexOf(name.value);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeArray.length;
change(themeArray[nextIndex]);
}
function toggle() {
let themeArray = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : ["light", "dark"];
cycle(themeArray);
}
const globalName = new Proxy(name, {
get(target, prop) {
return Reflect.get(target, prop);
},
set(target, prop, val) {
if (prop === "value") {
deprecate(`theme.global.name.value = ${val}`, `theme.change('${val}')`);
}
return Reflect.set(target, prop, val);
}
});
return {
install,
change,
cycle,
toggle,
isDisabled: parsedOptions.isDisabled,
isSystem,
name,
themes,
current,
computedThemes,
prefix: parsedOptions.prefix,
themeClasses,
styles,
global: {
name: globalName,
current
}
};
}
function provideTheme(props) {
getCurrentInstance("provideTheme");
const theme = inject(ThemeSymbol, null);
if (!theme) throw new Error("Could not find Vuetify theme injection");
const name = toRef(() => props.theme ?? theme.name.value);
const current = toRef(() => theme.themes.value[name.value]);
const themeClasses = toRef(() => theme.isDisabled ? void 0 : `${theme.prefix}theme--${name.value}`);
const newTheme = {
...theme,
name,
current,
themeClasses
};
provide(ThemeSymbol, newTheme);
return newTheme;
}
function useTheme() {
getCurrentInstance("useTheme");
const theme = inject(ThemeSymbol, null);
if (!theme) throw new Error("Could not find Vuetify theme injection");
return theme;
}
export {
ThemeSymbol,
makeThemeProps,
createTheme,
provideTheme,
useTheme
};
//# sourceMappingURL=chunk-3DSR7NCB.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}
+562
View File
@@ -0,0 +1,562 @@
import {
useForm
} from "./chunk-GAYXO4FM.js";
import {
VSlideYTransition
} from "./chunk-B4JFJDWM.js";
import {
makeDensityProps,
useDensity
} from "./chunk-TM3MIDYC.js";
import {
VIcon
} from "./chunk-CMZ4UYUX.js";
import {
MaybeTransition,
makeTransitionProps
} from "./chunk-CAGZXMOS.js";
import {
makeDimensionProps,
useDimension
} from "./chunk-6EZKHCRW.js";
import {
IconValue
} from "./chunk-OJDZBU5D.js";
import {
useLocale,
useRtl
} from "./chunk-ENTLCOCZ.js";
import {
useProxiedModel,
useToggleScope
} from "./chunk-KA77II4V.js";
import {
useTextColor
} from "./chunk-BHV5Z34M.js";
import {
makeThemeProps,
provideTheme
} from "./chunk-3DSR7NCB.js";
import {
EventProp,
callEvent,
genericComponent,
getCurrentInstance,
getCurrentInstanceName,
makeComponentProps,
pick,
propsFactory,
useRender,
wrapInArray
} from "./chunk-LZ5T2ERG.js";
import {
computed,
createBaseVNode,
createVNode,
inject,
mergeProps,
nextTick,
normalizeClass,
normalizeStyle,
onBeforeMount,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
toRef,
unref,
useId,
watch
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/components/VLabel/VLabel.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/components/VLabel/VLabel.css";
var makeVLabelProps = propsFactory({
text: String,
onClick: EventProp(),
...makeComponentProps(),
...makeThemeProps()
}, "VLabel");
var VLabel = genericComponent()({
name: "VLabel",
props: makeVLabelProps(),
setup(props, _ref) {
let {
slots
} = _ref;
useRender(() => createBaseVNode("label", {
"class": normalizeClass(["v-label", {
"v-label--clickable": !!props.onClick
}, props.class]),
"style": normalizeStyle(props.style),
"onClick": props.onClick
}, [props.text, slots.default?.()]));
return {};
}
});
// node_modules/vuetify/lib/components/VMessages/VMessages.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/components/VMessages/VMessages.css";
var makeVMessagesProps = propsFactory({
active: Boolean,
color: String,
messages: {
type: [Array, String],
default: () => []
},
...makeComponentProps(),
...makeTransitionProps({
transition: {
component: VSlideYTransition,
leaveAbsolute: true,
group: true
}
})
}, "VMessages");
var VMessages = genericComponent()({
name: "VMessages",
props: makeVMessagesProps(),
setup(props, _ref) {
let {
slots
} = _ref;
const messages = computed(() => wrapInArray(props.messages));
const {
textColorClasses,
textColorStyles
} = useTextColor(() => props.color);
useRender(() => createVNode(MaybeTransition, {
"transition": props.transition,
"tag": "div",
"class": normalizeClass(["v-messages", textColorClasses.value, props.class]),
"style": normalizeStyle([textColorStyles.value, props.style])
}, {
default: () => [props.active && messages.value.map((message, i) => createBaseVNode("div", {
"class": "v-messages__message",
"key": `${i}-${messages.value}`
}, [slots.message ? slots.message({
message
}) : message]))]
}));
return {};
}
});
// node_modules/vuetify/lib/components/VInput/VInput.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/components/VInput/VInput.css";
// node_modules/vuetify/lib/components/VInput/InputIcon.js
function useInputIcon(props) {
const {
t
} = useLocale();
function InputIcon(_ref) {
let {
name,
color,
...attrs
} = _ref;
const localeKey = {
prepend: "prependAction",
prependInner: "prependAction",
append: "appendAction",
appendInner: "appendAction",
clear: "clear"
}[name];
const listener = props[`onClick:${name}`];
function onKeydown(e) {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
callEvent(listener, new PointerEvent("click", e));
}
const label = listener && localeKey ? t(`$vuetify.input.${localeKey}`, props.label ?? "") : void 0;
return createVNode(VIcon, mergeProps({
"icon": props[`${name}Icon`],
"aria-label": label,
"onClick": listener,
"onKeydown": onKeydown,
"color": color
}, attrs), null);
}
return {
InputIcon
};
}
// node_modules/vuetify/lib/composables/focus.js
var makeFocusProps = propsFactory({
focused: Boolean,
"onUpdate:focused": EventProp()
}, "focus");
function useFocus(props) {
let name = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : getCurrentInstanceName();
const isFocused = useProxiedModel(props, "focused");
const focusClasses = toRef(() => {
return {
[`${name}--focused`]: isFocused.value
};
});
function focus() {
isFocused.value = true;
}
function blur() {
isFocused.value = false;
}
return {
focusClasses,
isFocused,
focus,
blur
};
}
// node_modules/vuetify/lib/labs/rules/rules.js
var RulesSymbol = /* @__PURE__ */ Symbol.for("vuetify:rules");
function useRules(fn) {
const rules = inject(RulesSymbol, null);
if (!fn) {
if (!rules) {
throw new Error("Could not find Vuetify rules injection");
}
return rules.aliases;
}
return rules?.resolve(fn) ?? toRef(fn);
}
// node_modules/vuetify/lib/composables/validation.js
var makeValidationProps = propsFactory({
disabled: {
type: Boolean,
default: null
},
error: Boolean,
errorMessages: {
type: [Array, String],
default: () => []
},
maxErrors: {
type: [Number, String],
default: 1
},
name: String,
label: String,
readonly: {
type: Boolean,
default: null
},
rules: {
type: Array,
default: () => []
},
modelValue: null,
validateOn: String,
validationValue: null,
...makeFocusProps()
}, "validation");
function useValidation(props) {
let name = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : getCurrentInstanceName();
let id = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : useId();
const model = useProxiedModel(props, "modelValue");
const validationModel = computed(() => props.validationValue === void 0 ? model.value : props.validationValue);
const form = useForm(props);
const rules = useRules(() => props.rules);
const internalErrorMessages = ref([]);
const isPristine = shallowRef(true);
const isDirty = computed(() => !!(wrapInArray(model.value === "" ? null : model.value).length || wrapInArray(validationModel.value === "" ? null : validationModel.value).length));
const errorMessages = computed(() => {
return props.errorMessages?.length ? wrapInArray(props.errorMessages).concat(internalErrorMessages.value).slice(0, Math.max(0, Number(props.maxErrors))) : internalErrorMessages.value;
});
const validateOn = computed(() => {
let value = (props.validateOn ?? form.validateOn?.value) || "input";
if (value === "lazy") value = "input lazy";
if (value === "eager") value = "input eager";
const set = new Set(value?.split(" ") ?? []);
return {
input: set.has("input"),
blur: set.has("blur") || set.has("input") || set.has("invalid-input"),
invalidInput: set.has("invalid-input"),
lazy: set.has("lazy"),
eager: set.has("eager")
};
});
const isValid = computed(() => {
if (props.error || props.errorMessages?.length) return false;
if (!props.rules.length) return true;
if (isPristine.value) {
return internalErrorMessages.value.length || validateOn.value.lazy ? null : true;
} else {
return !internalErrorMessages.value.length;
}
});
const isValidating = shallowRef(false);
const validationClasses = computed(() => {
return {
[`${name}--error`]: isValid.value === false,
[`${name}--dirty`]: isDirty.value,
[`${name}--disabled`]: form.isDisabled.value,
[`${name}--readonly`]: form.isReadonly.value
};
});
const vm = getCurrentInstance("validation");
const uid = computed(() => props.name ?? unref(id));
onBeforeMount(() => {
form.register?.({
id: uid.value,
vm,
validate,
reset,
resetValidation
});
});
onBeforeUnmount(() => {
form.unregister?.(uid.value);
});
onMounted(async () => {
if (!validateOn.value.lazy) {
await validate(!validateOn.value.eager);
}
form.update?.(uid.value, isValid.value, errorMessages.value);
});
useToggleScope(() => validateOn.value.input || validateOn.value.invalidInput && isValid.value === false, () => {
watch(validationModel, () => {
if (validationModel.value != null) {
validate();
} else if (props.focused) {
const unwatch = watch(() => props.focused, (val) => {
if (!val) validate();
unwatch();
});
}
});
});
useToggleScope(() => validateOn.value.blur, () => {
watch(() => props.focused, (val) => {
if (!val) validate();
});
});
watch([isValid, errorMessages], () => {
form.update?.(uid.value, isValid.value, errorMessages.value);
});
async function reset() {
model.value = null;
await nextTick();
await resetValidation();
}
async function resetValidation() {
isPristine.value = true;
if (!validateOn.value.lazy) {
await validate(!validateOn.value.eager);
} else {
internalErrorMessages.value = [];
}
}
async function validate() {
let silent = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : false;
const results = [];
isValidating.value = true;
for (const rule of rules.value) {
if (results.length >= Number(props.maxErrors ?? 1)) {
break;
}
const handler = typeof rule === "function" ? rule : () => rule;
const result = await handler(validationModel.value);
if (result === true) continue;
if (result !== false && typeof result !== "string") {
console.warn(`${result} is not a valid value. Rule functions must return boolean true or a string.`);
continue;
}
results.push(result || "");
}
internalErrorMessages.value = results;
isValidating.value = false;
isPristine.value = silent;
return internalErrorMessages.value;
}
return {
errorMessages,
isDirty,
isDisabled: form.isDisabled,
isReadonly: form.isReadonly,
isPristine,
isValid,
isValidating,
reset,
resetValidation,
validate,
validationClasses
};
}
// node_modules/vuetify/lib/components/VInput/VInput.js
var makeVInputProps = propsFactory({
id: String,
appendIcon: IconValue,
baseColor: String,
centerAffix: {
type: Boolean,
default: true
},
color: String,
glow: Boolean,
iconColor: [Boolean, String],
prependIcon: IconValue,
hideDetails: [Boolean, String],
hideSpinButtons: Boolean,
hint: String,
persistentHint: Boolean,
messages: {
type: [Array, String],
default: () => []
},
direction: {
type: String,
default: "horizontal",
validator: (v) => ["horizontal", "vertical"].includes(v)
},
"onClick:prepend": EventProp(),
"onClick:append": EventProp(),
...makeComponentProps(),
...makeDensityProps(),
...pick(makeDimensionProps(), ["maxWidth", "minWidth", "width"]),
...makeThemeProps(),
...makeValidationProps()
}, "VInput");
var VInput = genericComponent()({
name: "VInput",
props: {
...makeVInputProps()
},
emits: {
"update:modelValue": (value) => true
},
setup(props, _ref) {
let {
attrs,
slots,
emit
} = _ref;
const {
densityClasses
} = useDensity(props);
const {
dimensionStyles
} = useDimension(props);
const {
themeClasses
} = provideTheme(props);
const {
rtlClasses
} = useRtl();
const {
InputIcon
} = useInputIcon(props);
const uid = useId();
const id = computed(() => props.id || `input-${uid}`);
const {
errorMessages,
isDirty,
isDisabled,
isReadonly,
isPristine,
isValid,
isValidating,
reset,
resetValidation,
validate,
validationClasses
} = useValidation(props, "v-input", id);
const messages = computed(() => {
if (props.errorMessages?.length || !isPristine.value && errorMessages.value.length) {
return errorMessages.value;
} else if (props.hint && (props.persistentHint || props.focused)) {
return props.hint;
} else {
return props.messages;
}
});
const hasMessages = toRef(() => messages.value.length > 0);
const hasDetails = toRef(() => !props.hideDetails || props.hideDetails === "auto" && (hasMessages.value || !!slots.details));
const messagesId = computed(() => hasDetails.value ? `${id.value}-messages` : void 0);
const slotProps = computed(() => ({
id,
messagesId,
isDirty,
isDisabled,
isReadonly,
isPristine,
isValid,
isValidating,
hasDetails,
reset,
resetValidation,
validate
}));
const color = toRef(() => {
return props.error || props.disabled ? void 0 : props.focused ? props.color : props.baseColor;
});
const iconColor = toRef(() => {
if (!props.iconColor) return void 0;
return props.iconColor === true ? color.value : props.iconColor;
});
useRender(() => {
const hasPrepend = !!(slots.prepend || props.prependIcon);
const hasAppend = !!(slots.append || props.appendIcon);
return createBaseVNode("div", {
"class": normalizeClass(["v-input", `v-input--${props.direction}`, {
"v-input--center-affix": props.centerAffix,
"v-input--focused": props.focused,
"v-input--glow": props.glow,
"v-input--hide-spin-buttons": props.hideSpinButtons
}, densityClasses.value, themeClasses.value, rtlClasses.value, validationClasses.value, props.class]),
"style": normalizeStyle([dimensionStyles.value, props.style])
}, [hasPrepend && createBaseVNode("div", {
"key": "prepend",
"class": "v-input__prepend"
}, [slots.prepend ? slots.prepend(slotProps.value) : props.prependIcon && createVNode(InputIcon, {
"key": "prepend-icon",
"name": "prepend",
"color": iconColor.value
}, null)]), slots.default && createBaseVNode("div", {
"class": "v-input__control"
}, [slots.default?.(slotProps.value)]), hasAppend && createBaseVNode("div", {
"key": "append",
"class": "v-input__append"
}, [slots.append ? slots.append(slotProps.value) : props.appendIcon && createVNode(InputIcon, {
"key": "append-icon",
"name": "append",
"color": iconColor.value
}, null)]), hasDetails.value && createBaseVNode("div", {
"id": messagesId.value,
"class": "v-input__details",
"role": "alert",
"aria-live": "polite"
}, [createVNode(VMessages, {
"active": hasMessages.value,
"messages": messages.value
}, {
message: slots.message
}), slots.details?.(slotProps.value)])]);
});
return {
reset,
resetValidation,
validate,
isValid,
errorMessages
};
}
});
export {
VLabel,
useInputIcon,
makeFocusProps,
useFocus,
VMessages,
makeValidationProps,
useValidation,
makeVInputProps,
VInput
};
//# sourceMappingURL=chunk-4VABFUPG.js.map
File diff suppressed because one or more lines are too long
+118
View File
@@ -0,0 +1,118 @@
import {
IN_BROWSER,
deepEqual,
getCurrentInstance,
hasEvent,
propsFactory
} from "./chunk-LZ5T2ERG.js";
import {
computed,
nextTick,
onScopeDispose,
reactive,
resolveDynamicComponent,
toRef
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/composables/router.js
function useRoute() {
const vm = getCurrentInstance("useRoute");
return computed(() => vm?.proxy?.$route);
}
function useRouter() {
return getCurrentInstance("useRouter")?.proxy?.$router;
}
function useLink(props, attrs) {
const RouterLink = resolveDynamicComponent("RouterLink");
const isLink = toRef(() => !!(props.href || props.to));
const isClickable = computed(() => {
return isLink?.value || hasEvent(attrs, "click") || hasEvent(props, "click");
});
if (typeof RouterLink === "string" || !("useLink" in RouterLink)) {
const href2 = toRef(() => props.href);
return {
isLink,
isRouterLink: toRef(() => false),
isClickable,
href: href2,
linkProps: reactive({
href: href2
})
};
}
const routerLink = RouterLink.useLink({
to: toRef(() => props.to || ""),
replace: toRef(() => props.replace)
});
const link = computed(() => props.to ? routerLink : void 0);
const route = useRoute();
const isActive = computed(() => {
if (!link.value) return false;
if (!props.exact) return link.value.isActive?.value ?? false;
if (!route.value) return link.value.isExactActive?.value ?? false;
return link.value.isExactActive?.value && deepEqual(link.value.route.value.query, route.value.query);
});
const href = computed(() => props.to ? link.value?.route.value.href : props.href);
const isRouterLink = toRef(() => !!props.to);
return {
isLink,
isRouterLink,
isClickable,
isActive,
route: link.value?.route,
navigate: link.value?.navigate,
href,
linkProps: reactive({
href,
"aria-current": toRef(() => isActive.value ? "page" : void 0),
"aria-disabled": toRef(() => props.disabled && isLink.value ? "true" : void 0),
tabindex: toRef(() => props.disabled && isLink.value ? "-1" : void 0)
})
};
}
var makeRouterProps = propsFactory({
href: String,
replace: Boolean,
to: [String, Object],
exact: Boolean
}, "router");
var inTransition = false;
function useBackButton(router, cb) {
let popped = false;
let removeBefore;
let removeAfter;
if (IN_BROWSER && router?.beforeEach) {
nextTick(() => {
window.addEventListener("popstate", onPopstate);
removeBefore = router.beforeEach((to, from, next) => {
if (!inTransition) {
setTimeout(() => popped ? cb(next) : next());
} else {
popped ? cb(next) : next();
}
inTransition = true;
});
removeAfter = router?.afterEach(() => {
inTransition = false;
});
});
onScopeDispose(() => {
window.removeEventListener("popstate", onPopstate);
removeBefore?.();
removeAfter?.();
});
}
function onPopstate(e) {
if (e.state?.replaced) return;
popped = true;
setTimeout(() => popped = false);
}
}
export {
useRouter,
useLink,
makeRouterProps,
useBackButton
};
//# sourceMappingURL=chunk-573D4TYC.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../vuetify/src/composables/router.tsx"],
"sourcesContent": ["// Utilities\nimport {\n computed,\n nextTick,\n onScopeDispose, reactive,\n resolveDynamicComponent,\n toRef,\n} from 'vue'\nimport { deepEqual, getCurrentInstance, hasEvent, IN_BROWSER, propsFactory } from '@/util'\n\n// Types\nimport type { PropType, Ref, SetupContext } from 'vue'\nimport type {\n RouterLink as _RouterLink,\n useLink as _useLink,\n NavigationGuardNext,\n RouteLocationNormalizedLoaded,\n RouteLocationRaw,\n Router,\n} from 'vue-router'\nimport type { EventProp } from '@/util'\n\nexport function useRoute (): Ref<RouteLocationNormalizedLoaded | undefined> {\n const vm = getCurrentInstance('useRoute')\n\n return computed(() => vm?.proxy?.$route)\n}\n\nexport function useRouter (): Router | undefined {\n return getCurrentInstance('useRouter')?.proxy?.$router\n}\n\nexport interface LinkProps {\n href: string | undefined\n replace: boolean | undefined\n to: RouteLocationRaw | undefined\n exact: boolean | undefined\n disabled: boolean | undefined\n}\n\nexport interface LinkListeners {\n onClick?: EventProp | undefined\n onClickOnce?: EventProp | undefined\n}\n\nexport interface UseLink extends Omit<Partial<ReturnType<typeof _useLink>>, 'href'> {\n isLink: Readonly<Ref<boolean>>\n isRouterLink: Readonly<Ref<boolean>>\n isClickable: Readonly<Ref<boolean>>\n href: Ref<string | undefined>\n linkProps: Record<string, string | undefined>\n}\n\nexport function useLink (props: LinkProps & LinkListeners, attrs: SetupContext['attrs']): UseLink {\n const RouterLink = resolveDynamicComponent('RouterLink') as typeof _RouterLink | string\n\n const isLink = toRef(() => !!(props.href || props.to))\n const isClickable = computed(() => {\n return isLink?.value || hasEvent(attrs, 'click') || hasEvent(props, 'click')\n })\n\n if (typeof RouterLink === 'string' || !('useLink' in RouterLink)) {\n const href = toRef(() => props.href)\n return {\n isLink,\n isRouterLink: toRef(() => false),\n isClickable,\n href,\n linkProps: reactive({ href }),\n }\n }\n\n // vue-router useLink `to` prop needs to be reactive and useLink will crash if undefined\n const routerLink = RouterLink.useLink({\n to: toRef(() => props.to || ''),\n replace: toRef(() => props.replace),\n })\n // Actual link needs to be undefined when to prop is not used\n const link = computed(() => props.to ? routerLink : undefined)\n const route = useRoute()\n const isActive = computed(() => {\n if (!link.value) return false\n if (!props.exact) return link.value.isActive?.value ?? false\n if (!route.value) return link.value.isExactActive?.value ?? false\n\n return link.value.isExactActive?.value && deepEqual(link.value.route.value.query, route.value.query)\n })\n const href = computed(() => props.to ? link.value?.route.value.href : props.href)\n const isRouterLink = toRef(() => !!props.to)\n\n return {\n isLink,\n isRouterLink,\n isClickable,\n isActive,\n route: link.value?.route,\n navigate: link.value?.navigate,\n href,\n linkProps: reactive({\n href,\n 'aria-current': toRef(() => isActive.value ? 'page' : undefined),\n 'aria-disabled': toRef(() => props.disabled && isLink.value ? 'true' : undefined),\n tabindex: toRef(() => props.disabled && isLink.value ? '-1' : undefined),\n }),\n }\n}\n\nexport const makeRouterProps = propsFactory({\n href: String,\n replace: Boolean,\n to: [String, Object] as PropType<RouteLocationRaw>,\n exact: Boolean,\n}, 'router')\n\nlet inTransition = false\nexport function useBackButton (router: Router | undefined, cb: (next: NavigationGuardNext) => void) {\n let popped = false\n let removeBefore: (() => void) | undefined\n let removeAfter: (() => void) | undefined\n\n if (IN_BROWSER && router?.beforeEach) {\n nextTick(() => {\n window.addEventListener('popstate', onPopstate)\n removeBefore = router.beforeEach((to, from, next) => {\n if (!inTransition) {\n setTimeout(() => popped ? cb(next) : next())\n } else {\n popped ? cb(next) : next()\n }\n inTransition = true\n })\n removeAfter = router?.afterEach(() => {\n inTransition = false\n })\n })\n onScopeDispose(() => {\n window.removeEventListener('popstate', onPopstate)\n removeBefore?.()\n removeAfter?.()\n })\n }\n\n function onPopstate (e: PopStateEvent) {\n if (e.state?.replaced) return\n\n popped = true\n setTimeout(() => (popped = false))\n }\n}\n"],
"mappings": ";;;;;;;;;;;;;;;;;AAsBO,SAASA,WAA4D;AAC1E,QAAMC,KAAKC,mBAAmB,UAAU;AAExC,SAAOC,SAAS,MAAMF,IAAIG,OAAOC,MAAM;AACzC;AAEO,SAASC,YAAiC;AAC/C,SAAOJ,mBAAmB,WAAW,GAAGE,OAAOG;AACjD;AAuBO,SAASC,QAASC,OAAkCC,OAAuC;AAChG,QAAMC,aAAaC,wBAAwB,YAAY;AAEvD,QAAMC,SAASC,MAAM,MAAM,CAAC,EAAEL,MAAMM,QAAQN,MAAMO,GAAG;AACrD,QAAMC,cAAcd,SAAS,MAAM;AACjC,WAAOU,QAAQK,SAASC,SAAST,OAAO,OAAO,KAAKS,SAASV,OAAO,OAAO;EAC7E,CAAC;AAED,MAAI,OAAOE,eAAe,YAAY,EAAE,aAAaA,aAAa;AAChE,UAAMI,QAAOD,MAAM,MAAML,MAAMM,IAAI;AACnC,WAAO;MACLF;MACAO,cAAcN,MAAM,MAAM,KAAK;MAC/BG;MACAF,MAAAA;MACAM,WAAWC,SAAS;QAAEP,MAAAA;MAAK,CAAC;IAC9B;EACF;AAGA,QAAMQ,aAAaZ,WAAWH,QAAQ;IACpCQ,IAAIF,MAAM,MAAML,MAAMO,MAAM,EAAE;IAC9BQ,SAASV,MAAM,MAAML,MAAMe,OAAO;EACpC,CAAC;AAED,QAAMC,OAAOtB,SAAS,MAAMM,MAAMO,KAAKO,aAAaG,MAAS;AAC7D,QAAMC,QAAQ3B,SAAS;AACvB,QAAM4B,WAAWzB,SAAS,MAAM;AAC9B,QAAI,CAACsB,KAAKP,MAAO,QAAO;AACxB,QAAI,CAACT,MAAMoB,MAAO,QAAOJ,KAAKP,MAAMU,UAAUV,SAAS;AACvD,QAAI,CAACS,MAAMT,MAAO,QAAOO,KAAKP,MAAMY,eAAeZ,SAAS;AAE5D,WAAOO,KAAKP,MAAMY,eAAeZ,SAASa,UAAUN,KAAKP,MAAMS,MAAMT,MAAMc,OAAOL,MAAMT,MAAMc,KAAK;EACrG,CAAC;AACD,QAAMjB,OAAOZ,SAAS,MAAMM,MAAMO,KAAKS,KAAKP,OAAOS,MAAMT,MAAMH,OAAON,MAAMM,IAAI;AAChF,QAAMK,eAAeN,MAAM,MAAM,CAAC,CAACL,MAAMO,EAAE;AAE3C,SAAO;IACLH;IACAO;IACAH;IACAW;IACAD,OAAOF,KAAKP,OAAOS;IACnBM,UAAUR,KAAKP,OAAOe;IACtBlB;IACAM,WAAWC,SAAS;MAClBP;MACA,gBAAgBD,MAAM,MAAMc,SAASV,QAAQ,SAASQ,MAAS;MAC/D,iBAAiBZ,MAAM,MAAML,MAAMyB,YAAYrB,OAAOK,QAAQ,SAASQ,MAAS;MAChFS,UAAUrB,MAAM,MAAML,MAAMyB,YAAYrB,OAAOK,QAAQ,OAAOQ,MAAS;IACzE,CAAC;EACH;AACF;AAEO,IAAMU,kBAAkBC,aAAa;EAC1CtB,MAAMuB;EACNd,SAASe;EACTvB,IAAI,CAACsB,QAAQE,MAAM;EACnBX,OAAOU;AACT,GAAG,QAAQ;AAEX,IAAIE,eAAe;AACZ,SAASC,cAAeC,QAA4BC,IAAyC;AAClG,MAAIC,SAAS;AACb,MAAIC;AACJ,MAAIC;AAEJ,MAAIC,cAAcL,QAAQM,YAAY;AACpCC,aAAS,MAAM;AACbC,aAAOC,iBAAiB,YAAYC,UAAU;AAC9CP,qBAAeH,OAAOM,WAAW,CAACjC,IAAIsC,MAAMC,SAAS;AACnD,YAAI,CAACd,cAAc;AACjBe,qBAAW,MAAMX,SAASD,GAAGW,IAAI,IAAIA,KAAK,CAAC;QAC7C,OAAO;AACLV,mBAASD,GAAGW,IAAI,IAAIA,KAAK;QAC3B;AACAd,uBAAe;MACjB,CAAC;AACDM,oBAAcJ,QAAQc,UAAU,MAAM;AACpChB,uBAAe;MACjB,CAAC;IACH,CAAC;AACDiB,mBAAe,MAAM;AACnBP,aAAOQ,oBAAoB,YAAYN,UAAU;AACjDP,qBAAe;AACfC,oBAAc;IAChB,CAAC;EACH;AAEA,WAASM,WAAYO,GAAkB;AACrC,QAAIA,EAAEC,OAAOC,SAAU;AAEvBjB,aAAS;AACTW,eAAW,MAAOX,SAAS,KAAM;EACnC;AACF;",
"names": ["useRoute", "vm", "getCurrentInstance", "computed", "proxy", "$route", "useRouter", "$router", "useLink", "props", "attrs", "RouterLink", "resolveDynamicComponent", "isLink", "toRef", "href", "to", "isClickable", "value", "hasEvent", "isRouterLink", "linkProps", "reactive", "routerLink", "replace", "link", "undefined", "route", "isActive", "exact", "isExactActive", "deepEqual", "query", "navigate", "disabled", "tabindex", "makeRouterProps", "propsFactory", "String", "Boolean", "Object", "inTransition", "useBackButton", "router", "cb", "popped", "removeBefore", "removeAfter", "IN_BROWSER", "beforeEach", "nextTick", "window", "addEventListener", "onPopstate", "from", "next", "setTimeout", "afterEach", "onScopeDispose", "removeEventListener", "e", "state", "replaced"]
}
+290
View File
@@ -0,0 +1,290 @@
import {
isObject
} from "./chunk-LZ5T2ERG.js";
// node_modules/vuetify/lib/directives/ripple/index.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/directives/ripple/VRipple.css";
var stopSymbol = /* @__PURE__ */ Symbol("rippleStop");
var DELAY_RIPPLE = 80;
function transform(el, value) {
el.style.transform = value;
el.style.webkitTransform = value;
}
function isTouchEvent(e) {
return e.constructor.name === "TouchEvent";
}
function isKeyboardEvent(e) {
return e.constructor.name === "KeyboardEvent";
}
var calculate = function(e, el) {
let value = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {};
let localX = 0;
let localY = 0;
if (!isKeyboardEvent(e)) {
const offset = el.getBoundingClientRect();
const target = isTouchEvent(e) ? e.touches[e.touches.length - 1] : e;
localX = target.clientX - offset.left;
localY = target.clientY - offset.top;
}
let radius = 0;
let scale = 0.3;
if (el._ripple?.circle) {
scale = 0.15;
radius = el.clientWidth / 2;
radius = value.center ? radius : radius + Math.sqrt((localX - radius) ** 2 + (localY - radius) ** 2) / 4;
} else {
radius = Math.sqrt(el.clientWidth ** 2 + el.clientHeight ** 2) / 2;
}
const centerX = `${(el.clientWidth - radius * 2) / 2}px`;
const centerY = `${(el.clientHeight - radius * 2) / 2}px`;
const x = value.center ? centerX : `${localX - radius}px`;
const y = value.center ? centerY : `${localY - radius}px`;
return {
radius,
scale,
x,
y,
centerX,
centerY
};
};
var ripples = {
/* eslint-disable max-statements */
show(e, el) {
let value = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {};
if (!el?._ripple?.enabled) {
return;
}
const container = document.createElement("span");
const animation = document.createElement("span");
container.appendChild(animation);
container.className = "v-ripple__container";
if (value.class) {
container.className += ` ${value.class}`;
}
const {
radius,
scale,
x,
y,
centerX,
centerY
} = calculate(e, el, value);
const size = `${radius * 2}px`;
animation.className = "v-ripple__animation";
animation.style.width = size;
animation.style.height = size;
el.appendChild(container);
const computed = window.getComputedStyle(el);
if (computed && computed.position === "static") {
el.style.position = "relative";
el.dataset.previousPosition = "static";
}
animation.classList.add("v-ripple__animation--enter");
animation.classList.add("v-ripple__animation--visible");
transform(animation, `translate(${x}, ${y}) scale3d(${scale},${scale},${scale})`);
animation.dataset.activated = String(performance.now());
requestAnimationFrame(() => {
requestAnimationFrame(() => {
animation.classList.remove("v-ripple__animation--enter");
animation.classList.add("v-ripple__animation--in");
transform(animation, `translate(${centerX}, ${centerY}) scale3d(1,1,1)`);
});
});
},
hide(el) {
if (!el?._ripple?.enabled) return;
const ripples2 = el.getElementsByClassName("v-ripple__animation");
if (ripples2.length === 0) return;
const animation = Array.from(ripples2).findLast((ripple) => !ripple.dataset.isHiding);
if (!animation) return;
else animation.dataset.isHiding = "true";
const diff = performance.now() - Number(animation.dataset.activated);
const delay = Math.max(250 - diff, 0);
setTimeout(() => {
animation.classList.remove("v-ripple__animation--in");
animation.classList.add("v-ripple__animation--out");
setTimeout(() => {
const ripples3 = el.getElementsByClassName("v-ripple__animation");
if (ripples3.length === 1 && el.dataset.previousPosition) {
el.style.position = el.dataset.previousPosition;
delete el.dataset.previousPosition;
}
if (animation.parentNode?.parentNode === el) el.removeChild(animation.parentNode);
}, 300);
}, delay);
}
};
function isRippleEnabled(value) {
return typeof value === "undefined" || !!value;
}
function rippleShow(e) {
const value = {};
const element = e.currentTarget;
if (!element?._ripple || element._ripple.touched || e[stopSymbol]) return;
e[stopSymbol] = true;
if (isTouchEvent(e)) {
element._ripple.touched = true;
element._ripple.isTouch = true;
} else {
if (element._ripple.isTouch) return;
}
value.center = element._ripple.centered || isKeyboardEvent(e);
if (element._ripple.class) {
value.class = element._ripple.class;
}
if (isTouchEvent(e)) {
if (element._ripple.showTimerCommit) return;
element._ripple.showTimerCommit = () => {
ripples.show(e, element, value);
};
element._ripple.showTimer = window.setTimeout(() => {
if (element?._ripple?.showTimerCommit) {
element._ripple.showTimerCommit();
element._ripple.showTimerCommit = null;
}
}, DELAY_RIPPLE);
} else {
ripples.show(e, element, value);
}
}
function rippleStop(e) {
e[stopSymbol] = true;
}
function rippleHide(e) {
const element = e.currentTarget;
if (!element?._ripple) return;
window.clearTimeout(element._ripple.showTimer);
if (e.type === "touchend" && element._ripple.showTimerCommit) {
element._ripple.showTimerCommit();
element._ripple.showTimerCommit = null;
element._ripple.showTimer = window.setTimeout(() => {
rippleHide(e);
});
return;
}
window.setTimeout(() => {
if (element._ripple) {
element._ripple.touched = false;
}
});
ripples.hide(element);
}
function rippleCancelShow(e) {
const element = e.currentTarget;
if (!element?._ripple) return;
if (element._ripple.showTimerCommit) {
element._ripple.showTimerCommit = null;
}
window.clearTimeout(element._ripple.showTimer);
}
var keyboardRipple = false;
function keyboardRippleShow(e, keys) {
if (!keyboardRipple && keys.includes(e.key)) {
keyboardRipple = true;
rippleShow(e);
}
}
function keyboardRippleHide(e) {
keyboardRipple = false;
rippleHide(e);
}
function focusRippleHide(e) {
if (keyboardRipple) {
keyboardRipple = false;
rippleHide(e);
}
}
function updateRipple(el, binding, wasEnabled) {
const {
value,
modifiers
} = binding;
const enabled = isRippleEnabled(value);
if (!enabled) {
ripples.hide(el);
}
el._ripple = el._ripple ?? {};
el._ripple.enabled = enabled;
el._ripple.centered = modifiers.center;
el._ripple.circle = modifiers.circle;
const bindingValue = isObject(value) ? value : {};
if (bindingValue.class) {
el._ripple.class = bindingValue.class;
}
const allowedKeys = bindingValue.keys ?? ["Enter", "Space"];
el._ripple.keyDownHandler = (e) => keyboardRippleShow(e, allowedKeys);
if (enabled && !wasEnabled) {
if (modifiers.stop) {
el.addEventListener("touchstart", rippleStop, {
passive: true
});
el.addEventListener("mousedown", rippleStop);
return;
}
el.addEventListener("touchstart", rippleShow, {
passive: true
});
el.addEventListener("touchend", rippleHide, {
passive: true
});
el.addEventListener("touchmove", rippleCancelShow, {
passive: true
});
el.addEventListener("touchcancel", rippleHide);
el.addEventListener("mousedown", rippleShow);
el.addEventListener("mouseup", rippleHide);
el.addEventListener("mouseleave", rippleHide);
el.addEventListener("keydown", el._ripple.keyDownHandler);
el.addEventListener("keyup", keyboardRippleHide);
el.addEventListener("blur", focusRippleHide);
el.addEventListener("dragstart", rippleHide, {
passive: true
});
} else if (!enabled && wasEnabled) {
removeListeners(el);
}
}
function removeListeners(el) {
el.removeEventListener("touchstart", rippleStop);
el.removeEventListener("mousedown", rippleStop);
el.removeEventListener("touchstart", rippleShow);
el.removeEventListener("touchend", rippleHide);
el.removeEventListener("touchmove", rippleCancelShow);
el.removeEventListener("touchcancel", rippleHide);
el.removeEventListener("mousedown", rippleShow);
el.removeEventListener("mouseup", rippleHide);
el.removeEventListener("mouseleave", rippleHide);
if (el._ripple?.keyDownHandler) {
el.removeEventListener("keydown", el._ripple.keyDownHandler);
}
el.removeEventListener("keyup", keyboardRippleHide);
el.removeEventListener("blur", focusRippleHide);
el.removeEventListener("dragstart", rippleHide);
}
function mounted(el, binding) {
updateRipple(el, binding, false);
}
function unmounted(el) {
removeListeners(el);
delete el._ripple;
}
function updated(el, binding) {
if (binding.value === binding.oldValue) {
return;
}
const wasEnabled = isRippleEnabled(binding.oldValue);
updateRipple(el, binding, wasEnabled);
}
var Ripple = {
mounted,
unmounted,
updated
};
var ripple_default = Ripple;
export {
Ripple,
ripple_default
};
//# sourceMappingURL=chunk-5BPWKCNA.js.map
File diff suppressed because one or more lines are too long
+78
View File
@@ -0,0 +1,78 @@
import {
createForm,
makeFormProps
} from "./chunk-GAYXO4FM.js";
import {
forwardRefs
} from "./chunk-GUNW7S2L.js";
import {
genericComponent,
makeComponentProps,
propsFactory,
useRender
} from "./chunk-LZ5T2ERG.js";
import {
createBaseVNode,
normalizeClass,
normalizeStyle,
ref
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/components/VForm/VForm.js
var makeVFormProps = propsFactory({
...makeComponentProps(),
...makeFormProps()
}, "VForm");
var VForm = genericComponent()({
name: "VForm",
props: makeVFormProps(),
emits: {
"update:modelValue": (val) => true,
submit: (e) => true
},
setup(props, _ref) {
let {
slots,
emit
} = _ref;
const form = createForm(props);
const formRef = ref();
function onReset(e) {
e.preventDefault();
form.reset();
}
function onSubmit(_e) {
const e = _e;
const ready = form.validate();
e.then = ready.then.bind(ready);
e.catch = ready.catch.bind(ready);
e.finally = ready.finally.bind(ready);
emit("submit", e);
if (!e.defaultPrevented) {
ready.then((_ref2) => {
let {
valid
} = _ref2;
if (valid) {
formRef.value?.submit();
}
});
}
e.preventDefault();
}
useRender(() => createBaseVNode("form", {
"ref": formRef,
"class": normalizeClass(["v-form", props.class]),
"style": normalizeStyle(props.style),
"novalidate": true,
"onReset": onReset,
"onSubmit": onSubmit
}, [slots.default?.(form)]));
return forwardRefs(form, formRef);
}
});
export {
VForm
};
//# sourceMappingURL=chunk-5LZQZ6A5.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../vuetify/src/components/VForm/VForm.tsx"],
"sourcesContent": ["// Composables\nimport { makeComponentProps } from '@/composables/component'\nimport { createForm, makeFormProps } from '@/composables/form'\nimport { forwardRefs } from '@/composables/forwardRefs'\n\n// Utilities\nimport { ref } from 'vue'\nimport { genericComponent, propsFactory, useRender } from '@/util'\n\n// Types\nimport type { SubmitEventPromise } from '@/composables/form'\n\nexport const makeVFormProps = propsFactory({\n ...makeComponentProps(),\n ...makeFormProps(),\n}, 'VForm')\n\ntype VFormSlots = {\n default: ReturnType<typeof createForm>\n}\n\nexport const VForm = genericComponent<VFormSlots>()({\n name: 'VForm',\n\n props: makeVFormProps(),\n\n emits: {\n 'update:modelValue': (val: boolean | null) => true,\n submit: (e: SubmitEventPromise) => true,\n },\n\n setup (props, { slots, emit }) {\n const form = createForm(props)\n const formRef = ref<HTMLFormElement>()\n\n function onReset (e: Event) {\n e.preventDefault()\n form.reset()\n }\n\n function onSubmit (_e: Event) {\n const e = _e as SubmitEventPromise\n\n const ready = form.validate()\n e.then = ready.then.bind(ready)\n e.catch = ready.catch.bind(ready)\n e.finally = ready.finally.bind(ready)\n\n emit('submit', e)\n\n if (!e.defaultPrevented) {\n ready.then(({ valid }) => {\n if (valid) {\n formRef.value?.submit()\n }\n })\n }\n\n e.preventDefault()\n }\n\n useRender(() => ((\n <form\n ref={ formRef }\n class={[\n 'v-form',\n props.class,\n ]}\n style={ props.style }\n novalidate\n onReset={ onReset }\n onSubmit={ onSubmit }\n >\n { slots.default?.(form) }\n </form>\n )))\n\n return forwardRefs(form, formRef)\n },\n})\n\nexport type VForm = InstanceType<typeof VForm>\n"],
"mappings": ";;;;;;;;;;;;;;;;;;;;;AAYO,IAAMA,iBAAiBC,aAAa;EACzC,GAAGC,mBAAmB;EACtB,GAAGC,cAAc;AACnB,GAAG,OAAO;AAMH,IAAMC,QAAQC,iBAA6B,EAAE;EAClDC,MAAM;EAENC,OAAOP,eAAe;EAEtBQ,OAAO;IACL,qBAAsBC,SAAwB;IAC9CC,QAASC,OAA0B;EACrC;EAEAC,MAAOL,OAAKM,MAAmB;AAAA,QAAjB;MAAEC;MAAOC;IAAK,IAACF;AAC3B,UAAMG,OAAOC,WAAWV,KAAK;AAC7B,UAAMW,UAAUC,IAAqB;AAErC,aAASC,QAAST,GAAU;AAC1BA,QAAEU,eAAe;AACjBL,WAAKM,MAAM;IACb;AAEA,aAASC,SAAUC,IAAW;AAC5B,YAAMb,IAAIa;AAEV,YAAMC,QAAQT,KAAKU,SAAS;AAC5Bf,QAAEgB,OAAOF,MAAME,KAAKC,KAAKH,KAAK;AAC9Bd,QAAEkB,QAAQJ,MAAMI,MAAMD,KAAKH,KAAK;AAChCd,QAAEmB,UAAUL,MAAMK,QAAQF,KAAKH,KAAK;AAEpCV,WAAK,UAAUJ,CAAC;AAEhB,UAAI,CAACA,EAAEoB,kBAAkB;AACvBN,cAAME,KAAKK,WAAe;AAAA,cAAd;YAAEC;UAAM,IAACD;AACnB,cAAIC,OAAO;AACTf,oBAAQgB,OAAOxB,OAAO;UACxB;QACF,CAAC;MACH;AAEAC,QAAEU,eAAe;IACnB;AAEAc,cAAU,MAAAC,gBAAA,QAAA;MAAA,OAEAlB;MAAO,SAAAmB,eACN,CACL,UACA9B,MAAM+B,KAAK,CACZ;MAAA,SAAAC,eACOhC,MAAMiC,KAAK;MAAA,cAAA;MAAA,WAETpB;MAAO,YACNG;IAAQ,GAAA,CAEjBT,MAAM2B,UAAUzB,IAAI,CAAC,CAAA,CAEzB;AAEF,WAAO0B,YAAY1B,MAAME,OAAO;EAClC;AACF,CAAC;",
"names": ["makeVFormProps", "propsFactory", "makeComponentProps", "makeFormProps", "VForm", "genericComponent", "name", "props", "emits", "val", "submit", "e", "setup", "_ref", "slots", "emit", "form", "createForm", "formRef", "ref", "onReset", "preventDefault", "reset", "onSubmit", "_e", "ready", "validate", "then", "bind", "catch", "finally", "defaultPrevented", "_ref2", "valid", "value", "useRender", "_createElementVNode", "_normalizeClass", "class", "_normalizeStyle", "style", "default", "forwardRefs"]
}
+52
View File
@@ -0,0 +1,52 @@
import {
SUPPORTS_INTERSECTION
} from "./chunk-LZ5T2ERG.js";
// node_modules/vuetify/lib/directives/intersect/index.js
function mounted(el, binding) {
if (!SUPPORTS_INTERSECTION) return;
const modifiers = binding.modifiers || {};
const value = binding.value;
const {
handler,
options
} = typeof value === "object" ? value : {
handler: value,
options: {}
};
const observer = new IntersectionObserver(function() {
let entries = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : [];
let observer2 = arguments.length > 1 ? arguments[1] : void 0;
const _observe = el._observe?.[binding.instance.$.uid];
if (!_observe) return;
const isIntersecting = entries.some((entry) => entry.isIntersecting);
if (handler && (!modifiers.quiet || _observe.init) && (!modifiers.once || isIntersecting || _observe.init)) {
handler(isIntersecting, entries, observer2);
}
if (isIntersecting && modifiers.once) unmounted(el, binding);
else _observe.init = true;
}, options);
el._observe = Object(el._observe);
el._observe[binding.instance.$.uid] = {
init: false,
observer
};
observer.observe(el);
}
function unmounted(el, binding) {
const observe = el._observe?.[binding.instance.$.uid];
if (!observe) return;
observe.observer.unobserve(el);
delete el._observe[binding.instance.$.uid];
}
var Intersect = {
mounted,
unmounted
};
var intersect_default = Intersect;
export {
Intersect,
intersect_default
};
//# sourceMappingURL=chunk-5ONGSJQ2.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../vuetify/src/directives/intersect/index.ts"],
"sourcesContent": ["// Utilities\nimport { SUPPORTS_INTERSECTION } from '@/util'\n\n// Types\nimport type {\n DirectiveBinding,\n} from 'vue'\n\ntype ObserveHandler = (\n isIntersecting: boolean,\n entries: IntersectionObserverEntry[],\n observer: IntersectionObserver,\n) => void\n\nexport interface ObserveDirectiveBinding extends Omit<DirectiveBinding, 'modifiers' | 'value'> {\n value?: ObserveHandler | { handler: ObserveHandler, options?: IntersectionObserverInit }\n modifiers: {\n once?: boolean\n quiet?: boolean\n }\n}\n\nfunction mounted (el: HTMLElement, binding: ObserveDirectiveBinding) {\n if (!SUPPORTS_INTERSECTION) return\n\n const modifiers = binding.modifiers || {}\n const value = binding.value\n const { handler, options } = typeof value === 'object'\n ? value\n : { handler: value, options: {} }\n\n const observer = new IntersectionObserver((\n entries: IntersectionObserverEntry[] = [],\n observer: IntersectionObserver\n ) => {\n const _observe = el._observe?.[binding.instance!.$.uid]\n if (!_observe) return // Just in case, should never fire\n\n const isIntersecting = entries.some(entry => entry.isIntersecting)\n\n // If is not quiet or has already been\n // initted, invoke the user callback\n if (\n handler && (\n !modifiers.quiet ||\n _observe.init\n ) && (\n !modifiers.once ||\n isIntersecting ||\n _observe.init\n )\n ) {\n handler(isIntersecting, entries, observer)\n }\n\n if (isIntersecting && modifiers.once) unmounted(el, binding)\n else _observe.init = true\n }, options)\n\n el._observe = Object(el._observe)\n el._observe![binding.instance!.$.uid] = { init: false, observer }\n\n observer.observe(el)\n}\n\nfunction unmounted (el: HTMLElement, binding: ObserveDirectiveBinding) {\n const observe = el._observe?.[binding.instance!.$.uid]\n if (!observe) return\n\n observe.observer.unobserve(el)\n delete el._observe![binding.instance!.$.uid]\n}\n\nexport const Intersect = {\n mounted,\n unmounted,\n}\n\nexport default Intersect\n"],
"mappings": ";;;;;AAsBA,SAASA,QAASC,IAAiBC,SAAkC;AACnE,MAAI,CAACC,sBAAuB;AAE5B,QAAMC,YAAYF,QAAQE,aAAa,CAAC;AACxC,QAAMC,QAAQH,QAAQG;AACtB,QAAM;IAAEC;IAASC;EAAQ,IAAI,OAAOF,UAAU,WAC1CA,QACA;IAAEC,SAASD;IAAOE,SAAS,CAAC;EAAE;AAElC,QAAMC,WAAW,IAAIC,qBAAqB,WAGrC;AAAA,QAFHC,UAAoCC,UAAAC,SAAA,KAAAD,UAAA,CAAA,MAAAE,SAAAF,UAAA,CAAA,IAAG,CAAA;AAAE,QACzCH,YAA8BG,UAAAC,SAAA,IAAAD,UAAA,CAAA,IAAAE;AAE9B,UAAMC,WAAWb,GAAGa,WAAWZ,QAAQa,SAAUC,EAAEC,GAAG;AACtD,QAAI,CAACH,SAAU;AAEf,UAAMI,iBAAiBR,QAAQS,KAAKC,WAASA,MAAMF,cAAc;AAIjE,QACEZ,YACE,CAACF,UAAUiB,SACXP,SAASQ,UAET,CAAClB,UAAUmB,QACXL,kBACAJ,SAASQ,OAEX;AACAhB,cAAQY,gBAAgBR,SAASF,SAAQ;IAC3C;AAEA,QAAIU,kBAAkBd,UAAUmB,KAAMC,WAAUvB,IAAIC,OAAO;QACtDY,UAASQ,OAAO;EACvB,GAAGf,OAAO;AAEVN,KAAGa,WAAWW,OAAOxB,GAAGa,QAAQ;AAChCb,KAAGa,SAAUZ,QAAQa,SAAUC,EAAEC,GAAG,IAAI;IAAEK,MAAM;IAAOd;EAAS;AAEhEA,WAASkB,QAAQzB,EAAE;AACrB;AAEA,SAASuB,UAAWvB,IAAiBC,SAAkC;AACrE,QAAMwB,UAAUzB,GAAGa,WAAWZ,QAAQa,SAAUC,EAAEC,GAAG;AACrD,MAAI,CAACS,QAAS;AAEdA,UAAQlB,SAASmB,UAAU1B,EAAE;AAC7B,SAAOA,GAAGa,SAAUZ,QAAQa,SAAUC,EAAEC,GAAG;AAC7C;AAEO,IAAMW,YAAY;EACvB5B;EACAwB;AACF;AAEA,IAAA,oBAAeI;",
"names": ["mounted", "el", "binding", "SUPPORTS_INTERSECTION", "modifiers", "value", "handler", "options", "observer", "IntersectionObserver", "entries", "arguments", "length", "undefined", "_observe", "instance", "$", "uid", "isIntersecting", "some", "entry", "quiet", "init", "once", "unmounted", "Object", "observe", "unobserve", "Intersect"]
}
+387
View File
@@ -0,0 +1,387 @@
import {
makeRoundedProps,
useRounded
} from "./chunk-AQK3CYVW.js";
import {
intersect_default
} from "./chunk-5ONGSJQ2.js";
import {
MaybeTransition,
makeTransitionProps
} from "./chunk-CAGZXMOS.js";
import {
makeDimensionProps,
useDimension
} from "./chunk-6EZKHCRW.js";
import {
useBackgroundColor
} from "./chunk-BHV5Z34M.js";
import {
SUPPORTS_INTERSECTION,
convertToUnit,
genericComponent,
getCurrentInstance,
makeComponentProps,
propsFactory,
useRender
} from "./chunk-LZ5T2ERG.js";
import {
Fragment,
computed,
createBaseVNode,
createVNode,
mergeProps,
nextTick,
normalizeClass,
normalizeStyle,
onBeforeMount,
onBeforeUnmount,
ref,
shallowRef,
toRef,
vShow,
watch,
withDirectives
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/components/VImg/VImg.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/components/VImg/VImg.css";
// node_modules/vuetify/lib/components/VResponsive/VResponsive.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/components/VResponsive/VResponsive.css";
function useAspectStyles(props) {
return {
aspectStyles: computed(() => {
const ratio = Number(props.aspectRatio);
return ratio ? {
paddingBottom: String(1 / ratio * 100) + "%"
} : void 0;
})
};
}
var makeVResponsiveProps = propsFactory({
aspectRatio: [String, Number],
contentClass: null,
inline: Boolean,
...makeComponentProps(),
...makeDimensionProps()
}, "VResponsive");
var VResponsive = genericComponent()({
name: "VResponsive",
props: makeVResponsiveProps(),
setup(props, _ref) {
let {
slots
} = _ref;
const {
aspectStyles
} = useAspectStyles(props);
const {
dimensionStyles
} = useDimension(props);
useRender(() => createBaseVNode("div", {
"class": normalizeClass(["v-responsive", {
"v-responsive--inline": props.inline
}, props.class]),
"style": normalizeStyle([dimensionStyles.value, props.style])
}, [createBaseVNode("div", {
"class": "v-responsive__sizer",
"style": normalizeStyle(aspectStyles.value)
}, null), slots.additional?.(), slots.default && createBaseVNode("div", {
"class": normalizeClass(["v-responsive__content", props.contentClass])
}, [slots.default()])]));
return {};
}
});
// node_modules/vuetify/lib/components/VImg/VImg.js
var makeVImgProps = propsFactory({
absolute: Boolean,
alt: String,
cover: Boolean,
color: String,
draggable: {
type: [Boolean, String],
default: void 0
},
eager: Boolean,
gradient: String,
lazySrc: String,
options: {
type: Object,
// For more information on types, navigate to:
// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
default: () => ({
root: void 0,
rootMargin: void 0,
threshold: void 0
})
},
sizes: String,
src: {
type: [String, Object],
default: ""
},
crossorigin: String,
referrerpolicy: String,
srcset: String,
position: String,
...makeVResponsiveProps(),
...makeComponentProps(),
...makeRoundedProps(),
...makeTransitionProps()
}, "VImg");
var VImg = genericComponent()({
name: "VImg",
directives: {
vIntersect: intersect_default
},
props: makeVImgProps(),
emits: {
loadstart: (value) => true,
load: (value) => true,
error: (value) => true
},
setup(props, _ref) {
let {
emit,
slots
} = _ref;
const {
backgroundColorClasses,
backgroundColorStyles
} = useBackgroundColor(() => props.color);
const {
roundedClasses
} = useRounded(props);
const vm = getCurrentInstance("VImg");
const currentSrc = shallowRef("");
const image = ref();
const state = shallowRef(props.eager ? "loading" : "idle");
const naturalWidth = shallowRef();
const naturalHeight = shallowRef();
const normalisedSrc = computed(() => {
return props.src && typeof props.src === "object" ? {
src: props.src.src,
srcset: props.srcset || props.src.srcset,
lazySrc: props.lazySrc || props.src.lazySrc,
aspect: Number(props.aspectRatio || props.src.aspect || 0)
} : {
src: props.src,
srcset: props.srcset,
lazySrc: props.lazySrc,
aspect: Number(props.aspectRatio || 0)
};
});
const aspectRatio = computed(() => {
return normalisedSrc.value.aspect || naturalWidth.value / naturalHeight.value || 0;
});
watch(() => props.src, () => {
init(state.value !== "idle");
});
watch(aspectRatio, (val, oldVal) => {
if (!val && oldVal && image.value) {
pollForSize(image.value);
}
});
onBeforeMount(() => init());
function init(isIntersecting) {
if (props.eager && isIntersecting) return;
if (SUPPORTS_INTERSECTION && !isIntersecting && !props.eager) return;
state.value = "loading";
if (normalisedSrc.value.lazySrc) {
const lazyImg = new Image();
lazyImg.src = normalisedSrc.value.lazySrc;
pollForSize(lazyImg, null);
}
if (!normalisedSrc.value.src) return;
nextTick(() => {
emit("loadstart", image.value?.currentSrc || normalisedSrc.value.src);
setTimeout(() => {
if (vm.isUnmounted) return;
if (image.value?.complete) {
if (!image.value.naturalWidth) {
onError();
}
if (state.value === "error") return;
if (!aspectRatio.value) pollForSize(image.value, null);
if (state.value === "loading") onLoad();
} else {
if (!aspectRatio.value) pollForSize(image.value);
getSrc();
}
});
});
}
function onLoad() {
if (vm.isUnmounted) return;
getSrc();
pollForSize(image.value);
state.value = "loaded";
emit("load", image.value?.currentSrc || normalisedSrc.value.src);
}
function onError() {
if (vm.isUnmounted) return;
state.value = "error";
emit("error", image.value?.currentSrc || normalisedSrc.value.src);
}
function getSrc() {
const img = image.value;
if (img) currentSrc.value = img.currentSrc || img.src;
}
let timer = -1;
onBeforeUnmount(() => {
clearTimeout(timer);
});
function pollForSize(img) {
let timeout = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : 100;
const poll = () => {
clearTimeout(timer);
if (vm.isUnmounted) return;
const {
naturalHeight: imgHeight,
naturalWidth: imgWidth
} = img;
if (imgHeight || imgWidth) {
naturalWidth.value = imgWidth;
naturalHeight.value = imgHeight;
} else if (!img.complete && state.value === "loading" && timeout != null) {
timer = window.setTimeout(poll, timeout);
} else if (img.currentSrc.endsWith(".svg") || img.currentSrc.startsWith("data:image/svg+xml")) {
naturalWidth.value = 1;
naturalHeight.value = 1;
}
};
poll();
}
const containClasses = toRef(() => ({
"v-img__img--cover": props.cover,
"v-img__img--contain": !props.cover
}));
const __image = () => {
if (!normalisedSrc.value.src || state.value === "idle") return null;
const img = createBaseVNode("img", {
"class": normalizeClass(["v-img__img", containClasses.value]),
"style": {
objectPosition: props.position
},
"crossorigin": props.crossorigin,
"src": normalisedSrc.value.src,
"srcset": normalisedSrc.value.srcset,
"alt": props.alt,
"referrerpolicy": props.referrerpolicy,
"draggable": props.draggable,
"sizes": props.sizes,
"ref": image,
"onLoad": onLoad,
"onError": onError
}, null);
const sources = slots.sources?.();
return createVNode(MaybeTransition, {
"transition": props.transition,
"appear": true
}, {
default: () => [withDirectives(sources ? createBaseVNode("picture", {
"class": "v-img__picture"
}, [sources, img]) : img, [[vShow, state.value === "loaded"]])]
});
};
const __preloadImage = () => createVNode(MaybeTransition, {
"transition": props.transition
}, {
default: () => [normalisedSrc.value.lazySrc && state.value !== "loaded" && createBaseVNode("img", {
"class": normalizeClass(["v-img__img", "v-img__img--preload", containClasses.value]),
"style": {
objectPosition: props.position
},
"crossorigin": props.crossorigin,
"src": normalisedSrc.value.lazySrc,
"alt": props.alt,
"referrerpolicy": props.referrerpolicy,
"draggable": props.draggable
}, null)]
});
const __placeholder = () => {
if (!slots.placeholder) return null;
return createVNode(MaybeTransition, {
"transition": props.transition,
"appear": true
}, {
default: () => [(state.value === "loading" || state.value === "error" && !slots.error) && createBaseVNode("div", {
"class": "v-img__placeholder"
}, [slots.placeholder()])]
});
};
const __error = () => {
if (!slots.error) return null;
return createVNode(MaybeTransition, {
"transition": props.transition,
"appear": true
}, {
default: () => [state.value === "error" && createBaseVNode("div", {
"class": "v-img__error"
}, [slots.error()])]
});
};
const __gradient = () => {
if (!props.gradient) return null;
return createBaseVNode("div", {
"class": "v-img__gradient",
"style": {
backgroundImage: `linear-gradient(${props.gradient})`
}
}, null);
};
const isBooted = shallowRef(false);
{
const stop = watch(aspectRatio, (val) => {
if (val) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
isBooted.value = true;
});
});
stop();
}
});
}
useRender(() => {
const responsiveProps = VResponsive.filterProps(props);
return withDirectives(createVNode(VResponsive, mergeProps({
"class": ["v-img", {
"v-img--absolute": props.absolute,
"v-img--booting": !isBooted.value
}, backgroundColorClasses.value, roundedClasses.value, props.class],
"style": [{
width: convertToUnit(props.width === "auto" ? naturalWidth.value : props.width)
}, backgroundColorStyles.value, props.style]
}, responsiveProps, {
"aspectRatio": aspectRatio.value,
"aria-label": props.alt,
"role": props.alt ? "img" : void 0
}), {
additional: () => createBaseVNode(Fragment, null, [createVNode(__image, null, null), createVNode(__preloadImage, null, null), createVNode(__gradient, null, null), createVNode(__placeholder, null, null), createVNode(__error, null, null)]),
default: slots.default
}), [[intersect_default, {
handler: init,
options: props.options
}, null, {
once: true
}]]);
});
return {
currentSrc,
image,
state,
naturalWidth,
naturalHeight
};
}
});
export {
VResponsive,
makeVImgProps,
VImg
};
//# sourceMappingURL=chunk-6CRS5HI4.js.map
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
import {
convertToUnit,
propsFactory
} from "./chunk-LZ5T2ERG.js";
import {
computed
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/composables/dimensions.js
var makeDimensionProps = propsFactory({
height: [Number, String],
maxHeight: [Number, String],
maxWidth: [Number, String],
minHeight: [Number, String],
minWidth: [Number, String],
width: [Number, String]
}, "dimension");
function useDimension(props) {
const dimensionStyles = computed(() => {
const styles = {};
const height = convertToUnit(props.height);
const maxHeight = convertToUnit(props.maxHeight);
const maxWidth = convertToUnit(props.maxWidth);
const minHeight = convertToUnit(props.minHeight);
const minWidth = convertToUnit(props.minWidth);
const width = convertToUnit(props.width);
if (height != null) styles.height = height;
if (maxHeight != null) styles.maxHeight = maxHeight;
if (maxWidth != null) styles.maxWidth = maxWidth;
if (minHeight != null) styles.minHeight = minHeight;
if (minWidth != null) styles.minWidth = minWidth;
if (width != null) styles.width = width;
return styles;
});
return {
dimensionStyles
};
}
export {
makeDimensionProps,
useDimension
};
//# sourceMappingURL=chunk-6EZKHCRW.js.map
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../vuetify/src/composables/dimensions.ts"],
"sourcesContent": ["// Utilities\nimport { computed } from 'vue'\nimport { convertToUnit, propsFactory } from '@/util'\n\n// Types\nexport interface DimensionProps {\n height?: number | string\n maxHeight?: number | string\n maxWidth?: number | string\n minHeight?: number | string\n minWidth?: number | string\n width?: number | string\n}\n\n// Composables\nexport const makeDimensionProps = propsFactory({\n height: [Number, String],\n maxHeight: [Number, String],\n maxWidth: [Number, String],\n minHeight: [Number, String],\n minWidth: [Number, String],\n width: [Number, String],\n}, 'dimension')\n\nexport function useDimension (props: DimensionProps) {\n const dimensionStyles = computed(() => {\n const styles: Record<string, any> = {}\n\n const height = convertToUnit(props.height)\n const maxHeight = convertToUnit(props.maxHeight)\n const maxWidth = convertToUnit(props.maxWidth)\n const minHeight = convertToUnit(props.minHeight)\n const minWidth = convertToUnit(props.minWidth)\n const width = convertToUnit(props.width)\n\n if (height != null) styles.height = height\n if (maxHeight != null) styles.maxHeight = maxHeight\n if (maxWidth != null) styles.maxWidth = maxWidth\n if (minHeight != null) styles.minHeight = minHeight\n if (minWidth != null) styles.minWidth = minWidth\n if (width != null) styles.width = width\n\n return styles\n })\n\n return { dimensionStyles }\n}\n"],
"mappings": ";;;;;;;;;AAeO,IAAMA,qBAAqBC,aAAa;EAC7CC,QAAQ,CAACC,QAAQC,MAAM;EACvBC,WAAW,CAACF,QAAQC,MAAM;EAC1BE,UAAU,CAACH,QAAQC,MAAM;EACzBG,WAAW,CAACJ,QAAQC,MAAM;EAC1BI,UAAU,CAACL,QAAQC,MAAM;EACzBK,OAAO,CAACN,QAAQC,MAAM;AACxB,GAAG,WAAW;AAEP,SAASM,aAAcC,OAAuB;AACnD,QAAMC,kBAAkBC,SAAS,MAAM;AACrC,UAAMC,SAA8B,CAAC;AAErC,UAAMZ,SAASa,cAAcJ,MAAMT,MAAM;AACzC,UAAMG,YAAYU,cAAcJ,MAAMN,SAAS;AAC/C,UAAMC,WAAWS,cAAcJ,MAAML,QAAQ;AAC7C,UAAMC,YAAYQ,cAAcJ,MAAMJ,SAAS;AAC/C,UAAMC,WAAWO,cAAcJ,MAAMH,QAAQ;AAC7C,UAAMC,QAAQM,cAAcJ,MAAMF,KAAK;AAEvC,QAAIP,UAAU,KAAMY,QAAOZ,SAASA;AACpC,QAAIG,aAAa,KAAMS,QAAOT,YAAYA;AAC1C,QAAIC,YAAY,KAAMQ,QAAOR,WAAWA;AACxC,QAAIC,aAAa,KAAMO,QAAOP,YAAYA;AAC1C,QAAIC,YAAY,KAAMM,QAAON,WAAWA;AACxC,QAAIC,SAAS,KAAMK,QAAOL,QAAQA;AAElC,WAAOK;EACT,CAAC;AAED,SAAO;IAAEF;EAAgB;AAC3B;",
"names": ["makeDimensionProps", "propsFactory", "height", "Number", "String", "maxHeight", "maxWidth", "minHeight", "minWidth", "width", "useDimension", "props", "dimensionStyles", "computed", "styles", "convertToUnit"]
}
+257
View File
@@ -0,0 +1,257 @@
import {
VOverlay,
makeVOverlayProps
} from "./chunk-TS6Y6OVT.js";
import {
useScopeId
} from "./chunk-BVUPSGRF.js";
import {
forwardRefs
} from "./chunk-GUNW7S2L.js";
import {
useProxiedModel
} from "./chunk-KA77II4V.js";
import {
genericComponent,
keys,
omit,
propsFactory,
useRender
} from "./chunk-LZ5T2ERG.js";
import {
computed,
createVNode,
mergeProps,
ref,
toRef,
useId
} from "./chunk-2MKFL3BX.js";
// node_modules/vuetify/lib/directives/resize/index.js
function mounted(el, binding) {
const handler = binding.value;
const options = {
passive: !binding.modifiers?.active
};
window.addEventListener("resize", handler, options);
el._onResize = Object(el._onResize);
el._onResize[binding.instance.$.uid] = {
handler,
options
};
if (!binding.modifiers?.quiet) {
handler();
}
}
function unmounted(el, binding) {
if (!el._onResize?.[binding.instance.$.uid]) return;
const {
handler,
options
} = el._onResize[binding.instance.$.uid];
window.removeEventListener("resize", handler, options);
delete el._onResize[binding.instance.$.uid];
}
var Resize = {
mounted,
unmounted
};
var resize_default = Resize;
// node_modules/vuetify/lib/directives/touch/index.js
var handleGesture = (wrapper) => {
const {
touchstartX,
touchendX,
touchstartY,
touchendY
} = wrapper;
const dirRatio = 0.5;
const minDistance = 16;
wrapper.offsetX = touchendX - touchstartX;
wrapper.offsetY = touchendY - touchstartY;
if (Math.abs(wrapper.offsetY) < dirRatio * Math.abs(wrapper.offsetX)) {
wrapper.left && touchendX < touchstartX - minDistance && wrapper.left(wrapper);
wrapper.right && touchendX > touchstartX + minDistance && wrapper.right(wrapper);
}
if (Math.abs(wrapper.offsetX) < dirRatio * Math.abs(wrapper.offsetY)) {
wrapper.up && touchendY < touchstartY - minDistance && wrapper.up(wrapper);
wrapper.down && touchendY > touchstartY + minDistance && wrapper.down(wrapper);
}
};
function touchstart(event, wrapper) {
const touch = event.changedTouches[0];
wrapper.touchstartX = touch.clientX;
wrapper.touchstartY = touch.clientY;
wrapper.start?.({
originalEvent: event,
...wrapper
});
}
function touchend(event, wrapper) {
const touch = event.changedTouches[0];
wrapper.touchendX = touch.clientX;
wrapper.touchendY = touch.clientY;
wrapper.end?.({
originalEvent: event,
...wrapper
});
handleGesture(wrapper);
}
function touchmove(event, wrapper) {
const touch = event.changedTouches[0];
wrapper.touchmoveX = touch.clientX;
wrapper.touchmoveY = touch.clientY;
wrapper.move?.({
originalEvent: event,
...wrapper
});
}
function createHandlers() {
let value = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : {};
const wrapper = {
touchstartX: 0,
touchstartY: 0,
touchendX: 0,
touchendY: 0,
touchmoveX: 0,
touchmoveY: 0,
offsetX: 0,
offsetY: 0,
left: value.left,
right: value.right,
up: value.up,
down: value.down,
start: value.start,
move: value.move,
end: value.end
};
return {
touchstart: (e) => touchstart(e, wrapper),
touchend: (e) => touchend(e, wrapper),
touchmove: (e) => touchmove(e, wrapper)
};
}
function mounted2(el, binding) {
const value = binding.value;
const target = value?.parent ? el.parentElement : el;
const options = value?.options ?? {
passive: true
};
const uid = binding.instance?.$.uid;
if (!target || uid === void 0) return;
const handlers = createHandlers(binding.value);
target._touchHandlers = target._touchHandlers ?? /* @__PURE__ */ Object.create(null);
target._touchHandlers[uid] = handlers;
keys(handlers).forEach((eventName) => {
target.addEventListener(eventName, handlers[eventName], options);
});
}
function unmounted2(el, binding) {
const target = binding.value?.parent ? el.parentElement : el;
const uid = binding.instance?.$.uid;
if (!target?._touchHandlers || uid === void 0) return;
const handlers = target._touchHandlers[uid];
keys(handlers).forEach((eventName) => {
target.removeEventListener(eventName, handlers[eventName]);
});
delete target._touchHandlers[uid];
}
var Touch = {
mounted: mounted2,
unmounted: unmounted2
};
var touch_default = Touch;
// node_modules/vuetify/lib/components/VTooltip/VTooltip.js
import "/home/david/Documents/vscode/personal_development/ServerManagerWebApp/frontend/node_modules/vuetify/lib/components/VTooltip/VTooltip.css";
var makeVTooltipProps = propsFactory({
id: String,
interactive: Boolean,
text: String,
...omit(makeVOverlayProps({
closeOnBack: false,
location: "end",
locationStrategy: "connected",
eager: true,
minWidth: 0,
offset: 10,
openOnClick: false,
openOnHover: true,
origin: "auto",
scrim: false,
scrollStrategy: "reposition",
transition: null
}), ["absolute", "retainFocus", "captureFocus", "disableInitialFocus"])
}, "VTooltip");
var VTooltip = genericComponent()({
name: "VTooltip",
props: makeVTooltipProps(),
emits: {
"update:modelValue": (value) => true
},
setup(props, _ref) {
let {
slots
} = _ref;
const isActive = useProxiedModel(props, "modelValue");
const {
scopeId
} = useScopeId();
const uid = useId();
const id = toRef(() => props.id || `v-tooltip-${uid}`);
const overlay = ref();
const location = computed(() => {
return props.location.split(" ").length > 1 ? props.location : props.location + " center";
});
const origin = computed(() => {
return props.origin === "auto" || props.origin === "overlap" || props.origin.split(" ").length > 1 || props.location.split(" ").length > 1 ? props.origin : props.origin + " center";
});
const transition = toRef(() => {
if (props.transition != null) return props.transition;
return isActive.value ? "scale-transition" : "fade-transition";
});
const activatorProps = computed(() => mergeProps({
"aria-describedby": id.value
}, props.activatorProps));
useRender(() => {
const overlayProps = VOverlay.filterProps(props);
return createVNode(VOverlay, mergeProps({
"ref": overlay,
"class": ["v-tooltip", {
"v-tooltip--interactive": props.interactive
}, props.class],
"style": props.style,
"id": id.value
}, overlayProps, {
"modelValue": isActive.value,
"onUpdate:modelValue": ($event) => isActive.value = $event,
"transition": transition.value,
"absolute": true,
"location": location.value,
"origin": origin.value,
"role": "tooltip",
"activatorProps": activatorProps.value,
"_disableGlobalStack": true
}, scopeId), {
activator: slots.activator,
default: function() {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
return slots.default?.(...args) ?? props.text;
}
});
});
return forwardRefs({}, overlay);
}
});
export {
Resize,
resize_default,
Touch,
touch_default,
VTooltip
};
//# sourceMappingURL=chunk-6VKWM6MN.js.map

Some files were not shown because too many files have changed in this diff Show More