Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a667d1019 | ||
|
|
a475dfeab6 |
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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 @@
|
||||

|
||||

|
||||
|
||||
# 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:
|
||||
|
||||
[](https://buymeacoffee.com/emy_69)
|
||||
[](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:
|
||||
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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}')
|
||||
@@ -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 @@
|
||||
{"language": "en"}
|
||||
@@ -0,0 +1 @@
|
||||
False
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 268 B |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 480 B |
|
After Width: | Height: | Size: 365 B |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 268 B |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 587 B |
|
After Width: | Height: | Size: 693 B |
|
After Width: | Height: | Size: 412 B |
|
After Width: | Height: | Size: 242 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||