diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..0ecaeade0785e661cb98b77131382be2e9cf801e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.db +venv/ +.git/ +nohup.out +core diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..25f886b5a9f3778791d3032f97aca6ecda84c15e --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +# Python bytecode files +*.pyc +*.pyo +*.pyd +__pycache__/ + +# Virtual environment +venv/ +env/ + +# Distribution / packaging +*.egg +*.egg-info +dist/ +build/ +*.whl + +# IDE files +.idea/ +.vscode/ + +# Jupyter Notebook files +.ipynb_checkpoints + +# PyInstaller +*.manifest +*.spec + +# Test and coverage reports +.coverage +*.coveragerc +nosetests.xml +coverage.xml +*.coveralls.yml + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pytest +.cache/ + +# Sphinx documentation +docs/_build/ + +# pytest and flake8 +*.log + +# VS Code settings +.vscode/ + +# Django secrets +*.env + +# Flask instance folder +instance/ + +# PyCharm project files +.idea/ + +# Other Python-related files +*.bak +*.swp +*.swo +ddet_classification/ +.DS_Store +.pkl +people/ +people_backup/ +*.mp3 +*.wav +media/uploads/ +media/vtt/ +volumes/ +output/ +reports/ +data/ +ai_api/library/data/ +ai_api/library/output/ +ai_api/library/cache/ +ai_api/library/reports/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b7b4da3c1c2bd474694c789502327cb5004900d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.9-slim + + +ENV DEBIAN_FRONTEND=noninteractive +ENV TF_CPP_MIN_LOG_LEVEL=2 + +# Install dependencies +#RUN apt-get update && apt-get install -y exiftool ffmpeg curl libglib2.0-0 libsm6 libxext6 libxrender-dev +# Install Chrome & dependencies +RUN apt-get update && apt-get install -y \ + wget unzip curl gnupg exiftool ffmpeg \ + fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 libgdk-pixbuf2.0-0 \ + libnspr4 libnss3 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils libu2f-udev libvulkan1 \ + chromium chromium-driver \ + && rm -rf /var/lib/apt/lists/* + +# Set work directory +WORKDIR /app + +# Copy project files +COPY . /app + +# Install Python packages +RUN pip install --no-cache-dir -r requirements.txt + +# Expose port +EXPOSE 8000 + +# Run app using Gunicorn +#CMD ["gunicorn", "--bind", "0.0.0.0:8000", "devlab_next.wsgi:application"] +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + diff --git a/ai_api/.gitignore b/ai_api/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..8cd1372e447b34bf5f680773bd592a24dff8dfc0 --- /dev/null +++ b/ai_api/.gitignore @@ -0,0 +1,68 @@ +# Python bytecode files +*.pyc +*.pyo +*.pyd +__pycache__/ + +# Virtual environment +venv/ +env/ + +# Distribution / packaging +*.egg +*.egg-info +dist/ +build/ +*.whl + +# IDE files +.idea/ +.vscode/ + +# Jupyter Notebook files +.ipynb_checkpoints + +# PyInstaller +*.manifest +*.spec + +# Test and coverage reports +.coverage +*.coveragerc +nosetests.xml +coverage.xml +*.coveralls.yml + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pytest +.cache/ + +# Sphinx documentation +docs/_build/ + +# pytest and flake8 +*.log + +# VS Code settings +.vscode/ + +# Django secrets +*.env + +# Flask instance folder +instance/ + +# PyCharm project files +.idea/ + +# Other Python-related files +*.bak +*.swp +*.swo +ddet_classification/ +.DS_Store +.pkl \ No newline at end of file diff --git a/ai_api/__init__.py b/ai_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/ai_api/admin.py b/ai_api/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8cfc82ab7b449f95be5dada3c0148119e9aaf985 --- /dev/null +++ b/ai_api/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import APIClient + +# admin.site.register(APIClient) + +@admin.register(APIClient) +class APIClientAdmin(admin.ModelAdmin): + list_display = ('name', 'client_id', 'created_at') + readonly_fields = ('client_id', 'secret_key', 'created_at') + fields = ('name', 'client_id', 'secret_key', 'created_at') # show in form diff --git a/ai_api/api.py b/ai_api/api.py new file mode 100644 index 0000000000000000000000000000000000000000..a1c4d30c3d1f8d6069863b6fdd3ccb8db5ea50a8 --- /dev/null +++ b/ai_api/api.py @@ -0,0 +1,44 @@ +from django.shortcuts import render +from django.http import JsonResponse +from .forms import ImageUploadForm, ClassificationForm, RegisterFaceForm,TranscribeForm, YouTubeURLForm +import shutil +from django.conf import settings +import torch +import json +import os +from PIL import Image as PILImage +import io +import tempfile +from django.core.cache import cache +import numpy as numpy_lib +import pickle +from deepface import DeepFace +import cv2 +import base64 +from io import BytesIO +from . import globals +import tempfile +import mimetypes +import subprocess +import logging +import uuid +import yt_dlp +import time +import re +from pydub import AudioSegment +import pandas as pd +import csv +from .models import APIClient + +API_VERSION = '1.0.0' + +def index(request): + return JsonResponse({'message': 'Welcome to the BERNAMA Fact Check API', 'version': API_VERSION}) + +def clients(request): + # if not hasattr(request, 'api_client'): + # return JsonResponse({'error': 'Unauthorized'}, status=401) + + clients = list(APIClient.objects.values('name', 'client_id', 'created_at')) + return JsonResponse({'clients': clients}) + diff --git a/ai_api/api_urls.py b/ai_api/api_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..4b9cd1a1e3b9496501b61fbd7c1860f233dbab0d --- /dev/null +++ b/ai_api/api_urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import api, controllers + +urlpatterns = [ + path('', api.index, name='index'), + path('ping/', api.index, name='index'), + path('clients/', api.clients, name='clients'), + path('transcription/', controllers.transcription.TranscriptionAPIView.as_view(), name='transcription'), + path('classification/', controllers.classification.ClassificationAPIView.as_view(), name='classification'), +] diff --git a/ai_api/apps.py b/ai_api/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..cc63d029079240bca9524ebed9adb0e926248f73 --- /dev/null +++ b/ai_api/apps.py @@ -0,0 +1,63 @@ +from django.apps import AppConfig + +class AiApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ai_api' + + def ready(self): + from . import globals + from deepface import DeepFace + from ai_api.library.devlab_image import DevLabImage + from transformers import AutoTokenizer, AutoModelForSequenceClassification + import whisper + import os + from safetensors import safe_open + import torch + + device = "cuda" if torch.cuda.is_available() else "cpu" + + globals.devlab_image = DevLabImage() + + # Load HuggingFace tokenizer and model once + save_path = os.path.join(os.path.dirname(__file__), "ddet_classification") + print(f"Model path: {save_path}") + globals.save_path = save_path + + # Load tokenizer + try: + globals.tokenizer = AutoTokenizer.from_pretrained(save_path,device=device) + print("Tokenizer loaded ✅") + except Exception as e: + print(f"Failed to load tokenizer: {e}") + globals.tokenizer = None + + # Check .safetensors before loading model + try: + safetensor_file = os.path.join(save_path, "model.safetensors") + if os.path.exists(safetensor_file): + with safe_open(safetensor_file, framework="pt") as f: + print("Safetensors file checked ✅") + + globals.model = AutoModelForSequenceClassification.from_pretrained(save_path) + globals.model.eval() + print("Classification model loaded ✅") + + except Exception as e: + print(f"Failed to load classification model: {e}") + globals.model = None + + # Load Whisper model + try: + globals.whisper_model = whisper.load_model("large",device=device) + print("Whisper model loaded ✅") + except Exception as e: + print(f"Failed to load Whisper model: {e}") + globals.whisper_model = None + + # Load FaceNet model + try: + globals.facenet_model = DeepFace.build_model("Facenet") + print("Facenet model loaded ✅") + except Exception as e: + print(f"Failed to load FaceNet model: {e}") + globals.facenet_model = None diff --git a/ai_api/controllers/__init__.py b/ai_api/controllers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29806ed2516d6c651e6aa7a094c8342975a2e076 --- /dev/null +++ b/ai_api/controllers/__init__.py @@ -0,0 +1,2 @@ +from . import transcription +from . import classification \ No newline at end of file diff --git a/ai_api/controllers/classification.py b/ai_api/controllers/classification.py new file mode 100644 index 0000000000000000000000000000000000000000..8c54e8f9f4df51a601f510ca7f0627f9acaad9fb --- /dev/null +++ b/ai_api/controllers/classification.py @@ -0,0 +1,15 @@ +# classification.py +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from ..request_serializer import ClassificationRequestSerializer + +class ClassificationAPIView(APIView): + def get(self, request): + return Response({"message": "Classification API"}) + + def post(self, request): + serializer = ClassificationRequestSerializer(data=request.data) + if serializer.is_valid(): + return Response({"message": "Classification API"}) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/ai_api/controllers/transcription.py b/ai_api/controllers/transcription.py new file mode 100644 index 0000000000000000000000000000000000000000..37bde3df6bd5581492dfe935384e2323d53ba2fe --- /dev/null +++ b/ai_api/controllers/transcription.py @@ -0,0 +1,16 @@ +# transcription.py +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from ..request_serializer import TranscriptionRequestSerializer + +class TranscriptionAPIView(APIView): + def get(self, request): + return Response({"message": "Transcription API"}) + + def post(self, request): + serializer = TranscriptionRequestSerializer(data=request.data) + if serializer.is_valid(): + media_file = request.FILES.get('media') + return Response({"media_file": media_file.name}) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/ai_api/forms.py b/ai_api/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..2cb111c6cc6205496abe4f69aa28f19a7a34e060 --- /dev/null +++ b/ai_api/forms.py @@ -0,0 +1,86 @@ +from django import forms +from .widgets import MultipleFileInput +from django.core.exceptions import ValidationError + + +class ImageUploadForm(forms.Form): + image = forms.ImageField( + widget=forms.ClearableFileInput(attrs={ + 'class': 'form-control', + 'capture': 'user' + }) + ) + +class ClassificationForm(forms.Form): + claim = forms.CharField( + label="Claim:", + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 5, + 'placeholder': 'Enter your claim or statement', + }) + ) + +class RegisterFaceForm(forms.Form): + person = forms.CharField( + label="Person:", + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'e.g: ANWAR IBRAHIM', + }) + ) + keywords = forms.CharField( + label="Keyword:", + required=False, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'e.g: Prime Minister of Malaysia', + }) + ) + images = forms.FileField( + required=False, + widget=MultipleFileInput(attrs={ + 'multiple': True, + 'class': 'form-control', + 'capture': 'user' + }) + ) + +class TranscribeForm(forms.Form): + url = forms.CharField( + label="YouTube URL:", + required=False, + widget=forms.TextInput(attrs={ + 'type': 'url', + 'class': 'form-control', + 'placeholder': 'Enter YouTube URL', + + }) + ) + file = forms.FileField( + label="Upload Audio/Video File", + required=False, + widget=forms.ClearableFileInput(attrs={ + 'class': 'form-control', + 'accept': 'audio/*,video/*', + + }) + ) + def clean(self): + cleaned_data = super().clean() + url = cleaned_data.get("url") + file = cleaned_data.get("file") + + if not url and not file: + raise ValidationError("You must provide either a YouTube URL or upload a file.") + if url and file: + raise ValidationError("Please provide only one: YouTube URL or a file upload.") + +class YouTubeURLForm(forms.Form): + youtube_url = forms.URLField( + label='YouTube Video URL', + widget=forms.URLInput(attrs={ + 'class': 'form-control', + 'placeholder': 'https://www.youtube.com/watch?v=example' + }) + ) diff --git a/ai_api/globals.py b/ai_api/globals.py new file mode 100644 index 0000000000000000000000000000000000000000..41c9036f1c95e285d3c1f62132d33a5f929a2ab9 --- /dev/null +++ b/ai_api/globals.py @@ -0,0 +1,6 @@ +devlab_image = None +tokenizer = None +model = None +save_path = None +whisper_model = None +facenet_model = None \ No newline at end of file diff --git a/ai_api/library/apify_scraper.py b/ai_api/library/apify_scraper.py new file mode 100644 index 0000000000000000000000000000000000000000..0ba1cd21acc1a52dd0365d41f87c9c5e10e2649f --- /dev/null +++ b/ai_api/library/apify_scraper.py @@ -0,0 +1,893 @@ +# apify_scraper.py +# Updated version: Uses separate Apify tokens for Facebook and TikTok tasks + +import requests +import time +import pandas as pd +import os +import json +import hashlib +from datetime import datetime, timedelta + +# Create cache directory +CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache") +os.makedirs(CACHE_DIR, exist_ok=True) + +# Import configuration settings +try: + from .config import ( + # API tokens + APIFY_TOKEN, APIFY_TOKEN_FB, APIFY_TOKEN_TIKTOK, + # Task IDs + POST_TASK_ID_SEARCH, COMMENT_TASK_ID, TIKTOK_VIDEO_TASK_ID, TIKTOK_COMMENT_TASK_ID, + # Data source settings + USE_FACEBOOK, USE_TIKTOK, USE_SERPAPI, USE_SERPER, USE_DUCKDUCKGO, USE_LOWYAT, + # Comment settings + USE_COMMENTS, + # Result limits + FACEBOOK_MAX_RESULTS, TIKTOK_MAX_RESULTS, WEB_SEARCH_MAX_RESULTS, LOWYAT_MAX_THREADS, + # Lowyat Forum settings + LOWYAT_SECTIONS + ) + # Use settings from config + print("[✓] Using configuration from config.py") +except ImportError: + # Fallback to hardcoded settings + print("[⚠️] Config not found, using hardcoded settings") + # API tokens + APIFY_TOKEN = "apify_api_INtF6uUT4c6nOStYDYTllxuTBNSbng1IlTTB" + #APIFY_TOKEN_FB = APIFY_TOKEN + #APIFY_TOKEN_TIKTOK = APIFY_TOKEN + + # Actor task IDs + #POST_TASK_ID_SEARCH = "l5DitJrtfCyOfrjn6" # Facebook Search PPR (rajamohd/facebook-search-ppr-rm-bernama) + #COMMENT_TASK_ID = "qiAp6PQwkyYcLQiyC" # Facebook Comments Scraper (rajamohd/facebook-comments-scraper-task) + TIKTOK_VIDEO_TASK_ID = "rfk0BzRAjuLPbccaZ" # TikTok Data Extractor (devlab/tiktok-data-extractor-bernama2-video) + TIKTOK_COMMENT_TASK_ID = "rgXeWIhnXKRD5bjGp" # TikTok Comments Scraper (devlab/tiktok-comments-scraper-bernama2) + + + + # Data source settings + USE_FACEBOOK = True + USE_TIKTOK = True + USE_SERPAPI = True + USE_SERPER = True + USE_DUCKDUCKGO = True + USE_LOWYAT = True + + # Comment settings + USE_COMMENTS = True + + # Result limits + FACEBOOK_MAX_RESULTS = 100 + TIKTOK_MAX_RESULTS = 50 + WEB_SEARCH_MAX_RESULTS = 20 + LOWYAT_MAX_THREADS = 20 + + # Lowyat Forum settings + LOWYAT_SECTIONS = ["Kopitiam", "SeriousKopitiam", "Finance"] + +def run(keywords, output_path="output/claim_data.csv", fetch_comments=True, max_videos=30, max_comments=50, max_results=None): + """Run data collection from multiple sources and combine results + + Args: + keywords (list): List of keywords to search for + output_path (str): Path to save combined results + fetch_comments (bool): Whether to fetch comments for TikTok videos + max_videos (int): Maximum number of TikTok videos to fetch per keyword + max_comments (int): Maximum number of comments to fetch per TikTok video + max_results (int): Maximum results per source (overrides config settings) + + Returns: + pandas.DataFrame: Combined results from all sources + """ + all_records = [] + + # Use config settings if max_results not specified + fb_max = max_results or FACEBOOK_MAX_RESULTS + tiktok_max = max_results or TIKTOK_MAX_RESULTS + web_max = max_results or WEB_SEARCH_MAX_RESULTS + + # Create output directory if it doesn't exist + os.makedirs(os.path.dirname(output_path), exist_ok=True) + # os.makedirs(output_path, exist_ok=True) + + # Create a summary of data sources + sources_enabled = [] + if USE_FACEBOOK: sources_enabled.append("Facebook") + if USE_TIKTOK: sources_enabled.append("TikTok") + if USE_SERPAPI: sources_enabled.append("SerpApi") + if USE_SERPER: sources_enabled.append("Serper.dev") + if USE_DUCKDUCKGO: sources_enabled.append("DuckDuckGo") + if USE_LOWYAT: sources_enabled.append("Lowyat Forum") + + print(f"[📊] Data collection enabled for: {', '.join(sources_enabled)}") + print(f"[🔍] Original Keywords: {', '.join(keywords)}") + + # Optimize keywords for different platforms + try: + from tiktok_keyword_formatter import optimize_keywords_for_platforms + optimized_keywords = optimize_keywords_for_platforms(keywords) + tiktok_keywords = optimized_keywords["tiktok"] + web_keywords = optimized_keywords["web_search"] + + print(f"[🔍] TikTok Keywords: {', '.join(tiktok_keywords)}") + print(f"[🔍] Web Search Keywords: {', '.join(web_keywords)}") + except ImportError: + print("[⚠️] Keyword formatter not found. Using original keywords for all platforms.") + tiktok_keywords = keywords + web_keywords = keywords + + # Facebook post search + if USE_FACEBOOK: + try: + boolean_query = build_boolean_search(keywords) + print(f"[📘] Facebook: {boolean_query}") + post_input = {"search": boolean_query, "resultsPerPage": min(fb_max, 100)} + + post_dataset_id = run_actor_task(POST_TASK_ID_SEARCH, post_input, platform="facebook") + posts = download_dataset(post_dataset_id, platform="facebook") + print(f"[📘] Retrieved {len(posts)} Facebook posts") + + fb_records = [] + for post in posts: + # Check if this is Malaysian content + username = post.get("username", "") + text = post.get("text", "") + post_url = post.get("url") + + if is_malaysian_content(username, text): + # Add the post itself + post_record = { + "platform": "facebook", + "date": post.get("createdAt"), + "username": username, + "post_text": text, + "post_url": post_url, + "likes": post.get("likes", 0), + "shares": post.get("shares", 0), + "comments_count": post.get("commentsCount", 0), + "comment_text": "", + "combined_text": text + } + fb_records.append(post_record) + + # If comments are enabled and the post has comments, scrape them + if USE_COMMENTS and post.get("commentsCount", 0) > 0 and post_url: + try: + print(f"[💬] Scraping comments for Facebook post: {post_url}") + comment_input = {"url": post_url, "maxComments": 50} + comment_dataset_id = run_actor_task(COMMENT_TASK_ID, comment_input, platform="facebook") + comments = download_dataset(comment_dataset_id, platform="facebook") + print(f"[💬] Retrieved {len(comments)} comments for post") + + for comment in comments: + comment_text = comment.get("text", "") + comment_username = comment.get("name", "") + + if is_malaysian_content(comment_username, comment_text): + comment_record = { + "platform": "facebook_comment", + "date": comment.get("date"), + "username": comment_username, + "post_text": "", + "post_url": post_url, + "likes": comment.get("likes", 0), + "shares": 0, + "comments_count": 0, + "comment_text": comment_text, + "combined_text": comment_text + } + fb_records.append(comment_record) + except Exception as e: + print(f"[❌] Error scraping comments for post {post_url}: {str(e)}") + print("[⚠️] Continuing with next post...") + + print(f"[📊] Added {len(fb_records)} Facebook records after filtering") + all_records.extend(fb_records) + except Exception as e: + print(f"[❌] Error during Facebook scraping: {str(e)}") + print("[⚠️] Continuing with other data sources...") + + # TikTok scraping + if USE_TIKTOK: + try: + print(f"[📽️] TikTok: Searching for {', '.join(tiktok_keywords)}") + tiktok_records = [] + + # Use only the top 3 most relevant keywords as requested + top_keywords = tiktok_keywords[:min(3, len(tiktok_keywords))] + print(f"[📽️] Using top {len(top_keywords)} TikTok keywords: {', '.join(top_keywords)}") + + # Set video limits as requested by user + videos_per_keyword = max_videos # Use the parameter value + + # No total video limit - collect exactly max_videos per keyword + total_videos_collected = 0 + max_total_videos = max_videos * len(top_keywords) # Allow max_videos per keyword + + # for keyword in top_keywords: + try: + # Print detailed debugging information + print(f"[📽️] DEBUG: TikTok API Token: {APIFY_TOKEN_TIKTOK[:5]}...{APIFY_TOKEN_TIKTOK[-5:]}") + print(f"[📽️] DEBUG: TikTok Video Task ID: {TIKTOK_VIDEO_TASK_ID}") + print(f"[📽️] DEBUG: TikTok Comment Task ID: {TIKTOK_COMMENT_TASK_ID}") + + keyword = ', '.join(tiktok_keywords) + + # Limit videos per keyword to save costs + tiktok_input = { "searchQueries": [keyword], "maxVideos": videos_per_keyword} + # tiktok_input ={"searchQueries": keyword} + print(f"[📽️] Requesting {videos_per_keyword} TikTok videos for: {keyword}") + print(f"[📽️] DEBUG: Full input payload: {tiktok_input}") + + + try: + tiktok_dataset_id = run_actor_task(TIKTOK_VIDEO_TASK_ID, tiktok_input, platform="tiktok") + print(f"[📽️] DEBUG: Successfully got dataset ID: {tiktok_dataset_id}") + videos = download_dataset(tiktok_dataset_id, platform="tiktok") + print(f"[📽️] Retrieved {len(videos)} TikTok videos for: {keyword}") + except Exception as e: + print(f"[❌] DETAILED ERROR in TikTok video extraction: {str(e)}") + print(f"[❌] Error type: {type(e).__name__}") + import traceback + print(f"[❌] Traceback: {traceback.format_exc()}") + videos = [] + + for video in videos: + # Check if we've reached the maximum total videos limit + if total_videos_collected >= max_total_videos: + print(f"[⚠️] Reached maximum limit of {max_total_videos} videos. Stopping collection.") + break + + username = video.get("authorMeta", {}).get("userName", "") or video.get("authorMeta", {}).get("name", "") + caption = video.get("text", "") + + if is_malaysian_content(username, caption): + # Increment the total videos counter + total_videos_collected += 1 + video_url = video.get("webVideoUrl") or video.get("videoUrl") + clean_url = video_url.split("?")[0] if video_url and "/video/" in video_url else None + + video_record = { + "platform": "tiktok", + "date": video.get("createTimeISO") or video.get("createTime"), + "username": username, + "post_text": caption, + "post_url": clean_url, + "likes": video.get("diggCount", 0), + "shares": video.get("shareCount", 0), + "comments_count": video.get("commentCount", 0), + "comment_text": "", + "combined_text": caption + } + + tiktok_records.append(video_record) + + # If comments are enabled and the video has comments, scrape them + # Get comments per video as requested by the user + min_comments_threshold = 5 # Lower threshold to ensure we get comments + max_comments_to_scrape = max_comments # Use the parameter value + max_videos_with_comments = 10 # Allow more videos with comments + + # Track how many videos we've scraped comments for + if not hasattr(run, 'videos_with_comments_count'): + run.videos_with_comments_count = 0 + + if (fetch_comments and + run.videos_with_comments_count < max_videos_with_comments and + video.get("commentCount", 0) >= min_comments_threshold and + clean_url and + video.get("diggCount", 0) > 10): # Very low threshold to ensure we get comments for most videos + try: + print(f"[💬] Scraping comments for popular TikTok video ({run.videos_with_comments_count+1}/{max_videos_with_comments}): {clean_url}") + comment_input = {"postURLs": [clean_url], "commentsPerPost": max_comments_to_scrape} + print(f"[💬] DEBUG: Comment input payload: {comment_input}") + + try: + comment_dataset_id = run_actor_task(TIKTOK_COMMENT_TASK_ID, comment_input, platform="tiktok") + print(f"[💬] DEBUG: Successfully got comment dataset ID: {comment_dataset_id}") + comments = download_dataset(comment_dataset_id, platform="tiktok") + run.videos_with_comments_count += 1 + print(f"[💬] Retrieved {len(comments)} comments for video") + except Exception as e: + print(f"[❌] DETAILED ERROR in TikTok comment extraction: {str(e)}") + print(f"[❌] Error type: {type(e).__name__}") + import traceback + print(f"[❌] Traceback: {traceback.format_exc()}") + comments = [] + + for comment in comments: + comment_text = comment.get("text", "") + comment_username = comment.get("author", {}).get("uniqueId", "") or comment.get("author", {}).get("nickname", "") + + if is_malaysian_content(comment_username, comment_text): + comment_record = { + "platform": "tiktok_comment", + "date": comment.get("createTime"), + "username": comment_username, + "post_text": "", + "post_url": clean_url, + "likes": comment.get("diggCount", 0), + "shares": 0, + "comments_count": 0, + "comment_text": comment_text, + "combined_text": comment_text + } + tiktok_records.append(comment_record) + except Exception as e: + print(f"[❌] Error scraping comments for video {clean_url}: {str(e)}") + print("[⚠️] Continuing with next video...") + # Check if we've reached the maximum total videos limit after processing this keyword + if total_videos_collected >= max_total_videos: + print(f"[⚠️] Reached maximum limit of {max_total_videos} videos. Stopping keyword search.") + break + except Exception as e: + print(f"[❌] Error processing TikTok keyword '{keyword}': {str(e)}") + print("[⚠️] Continuing with next keyword...") + + print(f"[📊] Added {len(tiktok_records)} TikTok records after filtering") + all_records.extend(tiktok_records) + except Exception as e: + print(f"[❌] Error during TikTok scraping: {str(e)}") + print("[⚠️] Continuing with other data sources...") + + # Web search (SerpApi, Serper.dev, DuckDuckGo) + if USE_SERPAPI or USE_SERPER or USE_DUCKDUCKGO: + try: + print(f"[🌐] Web Search: Searching for {', '.join(web_keywords)}") + web_search_output = f"output/{os.path.basename(output_path).split('.')[0]}_web.csv" + + # Try to import the run_web_search function + try: + from run_web_search import run_web_search + + # Get the full claim from the environment if available + full_claim = os.environ.get("FULL_CLAIM", None) + if full_claim: + print(f"[🔍] Using full claim for web search: {full_claim}") + + # Pass configuration settings to run_web_search + web_results_count = run_web_search( + web_keywords, + web_search_output, + num_results=web_max, + use_serpapi=USE_SERPAPI, + use_serper=USE_SERPER, + use_duckduckgo=USE_DUCKDUCKGO, + full_claim=full_claim + ) + print(f"[🌐] Retrieved {web_results_count} web search results") + + # If web search was successful, read the results and add to all_records + if web_results_count > 0: + try: + web_df = pd.read_csv(web_search_output) + web_records = web_df.to_dict('records') + all_records.extend(web_records) + print(f"[📊] Added {len(web_records)} web search records") + except Exception as e: + print(f"[❌] Error reading web search results: {str(e)}") + except ImportError: + print("[⚠️] Web search module not found. Skipping web search.") + except Exception as e: + print(f"[❌] Error during web search: {str(e)}") + + # Lowyat Forum data collection + if USE_LOWYAT: + try: + print(f"[📚] Collecting data from Lowyat Forum...") + + # Import the Lowyat Forum crawler + try: + from lowyat_crawler import run_lowyat_crawler + + # Use the same keywords for Lowyat Forum + lowyat_keywords = keywords + + # Check for environment variable override for sections + sections_to_use = LOWYAT_SECTIONS + if os.environ.get("LOWYAT_SECTIONS"): + sections_to_use = os.environ.get("LOWYAT_SECTIONS").split(",") + print(f"[📚] Using Lowyat Forum sections from environment: {', '.join(sections_to_use)}") + + # Get the full claim from the environment if available + full_claim = os.environ.get("FULL_CLAIM", None) + if full_claim: + print(f"[🔍] Using full claim for Lowyat Forum search: {full_claim}") + + # Get Lowyat Forum data + lowyat_output_path = output_path.replace(".csv", "_lowyat.csv") + try: + lowyat_df = run_lowyat_crawler( + lowyat_keywords, + sections=sections_to_use, + max_threads=LOWYAT_MAX_THREADS, + output_path=lowyat_output_path, + full_claim=full_claim + ) + + # Convert DataFrame to records and add to all_records + if not lowyat_df.empty: + lowyat_records = lowyat_df.to_dict('records') + all_records.extend(lowyat_records) + print(f"[📚] Added {len(lowyat_records)} Lowyat Forum records") + else: + print(f"[⚠️] No Lowyat Forum data found for keywords: {', '.join(lowyat_keywords)}") + + # Generate sample data for testing if needed + if os.environ.get("GENERATE_SAMPLE_LOWYAT_DATA", "false").lower() == "true": + print("[📚] Generating sample Lowyat Forum data for testing...") + + # Create a sample dataframe with the claim + from datetime import datetime + current_date = datetime.now().strftime('%Y-%m-%d') + + # Get the claim text or keywords + claim_text = full_claim if full_claim else ', '.join(lowyat_keywords) + + # Create relevant sample data based on claim content + sample_data = [] + + # Check for different types of claims and create relevant sample data + if any(term in claim_text.lower() for term in ['hon', 'tenonet', 'kenderaan', 'kereta']): + # Horn/vehicle related claim + sample_data.append({ + 'platform': 'LowyatForum', + 'date': current_date, + 'username': 'CarEnthusiast', + 'post_text': f"Adakah sesiapa tahu tentang undang-undang berkaitan hon tenonet? Saya dengar JPJ sedang menjalankan operasi terhadap kenderaan yang menggunakan hon jenis ini.", + 'post_url': 'https://forum.lowyat.net/topic/hon-tenonet', + 'likes': 15, + 'shares': 3, + 'comments_count': 8, + 'comment_text': '', + 'combined_text': f"Adakah sesiapa tahu tentang undang-undang berkaitan hon tenonet? Saya dengar JPJ sedang menjalankan operasi terhadap kenderaan yang menggunakan hon jenis ini." + }) + + sample_data.append({ + 'platform': 'LowyatForum_Comment', + 'date': current_date, + 'username': 'LegalExpert', + 'post_text': '', + 'post_url': 'https://forum.lowyat.net/topic/hon-tenonet#comment1', + 'likes': 7, + 'shares': 0, + 'comments_count': 0, + 'comment_text': "Ya, penggunaan hon tenonet adalah menyalahi undang-undang kerana boleh mengelirukan pemandu lain dan menyebabkan kemalangan. Denda boleh mencecah RM2,000.", + 'combined_text': "Ya, penggunaan hon tenonet adalah menyalahi undang-undang kerana boleh mengelirukan pemandu lain dan menyebabkan kemalangan. Denda boleh mencecah RM2,000." + }) + + elif any(term in claim_text.lower() for term in ['kelantan', 'rogol', 'sumbang mahram', 'jenayah']): + # Crime in Kelantan related claim + sample_data.append({ + 'platform': 'LowyatForum', + 'date': current_date, + 'username': 'SocialObserver', + 'post_text': f"Statistik jenayah seksual di Kelantan semakin membimbangkan. Menurut laporan polis, kes rogol dan sumbang mahram meningkat sebanyak 15% tahun ini.", + 'post_url': 'https://forum.lowyat.net/topic/crime-statistics', + 'likes': 12, + 'shares': 5, + 'comments_count': 7, + 'comment_text': '', + 'combined_text': f"Statistik jenayah seksual di Kelantan semakin membimbangkan. Menurut laporan polis, kes rogol dan sumbang mahram meningkat sebanyak 15% tahun ini." + }) + + sample_data.append({ + 'platform': 'LowyatForum_Comment', + 'date': current_date, + 'username': 'CommunityLeader', + 'post_text': '', + 'post_url': 'https://forum.lowyat.net/topic/crime-statistics#comment1', + 'likes': 8, + 'shares': 0, + 'comments_count': 0, + 'comment_text': "Kita perlu lebih banyak program kesedaran dan pendidikan untuk menangani masalah ini. Pihak berkuasa juga perlu mengambil tindakan lebih tegas terhadap pesalah.", + 'combined_text': "Kita perlu lebih banyak program kesedaran dan pendidikan untuk menangani masalah ini. Pihak berkuasa juga perlu mengambil tindakan lebih tegas terhadap pesalah." + }) + + elif any(term in claim_text.lower() for term in ['kelongsong', 'peluru', 'senjata', 'tan']): + # Ammunition related claim + sample_data.append({ + 'platform': 'LowyatForum', + 'date': current_date, + 'username': 'SecurityAnalyst', + 'post_text': f"Penemuan 50 tan kelongsong dan peluru di kilang haram membimbangkan. Adakah ini menunjukkan ancaman keselamatan yang serius?", + 'post_url': 'https://forum.lowyat.net/topic/security-threat', + 'likes': 25, + 'shares': 10, + 'comments_count': 15, + 'comment_text': '', + 'combined_text': f"Penemuan 50 tan kelongsong dan peluru di kilang haram membimbangkan. Adakah ini menunjukkan ancaman keselamatan yang serius?" + }) + + sample_data.append({ + 'platform': 'LowyatForum_Comment', + 'date': current_date, + 'username': 'DefenseExpert', + 'post_text': '', + 'post_url': 'https://forum.lowyat.net/topic/security-threat#comment1', + 'likes': 18, + 'shares': 0, + 'comments_count': 0, + 'comment_text': "Menurut sumber, kelongsong tersebut adalah untuk dikitar semula dan bukan untuk kegunaan senjata aktif. Namun, ia tetap menyalahi undang-undang kerana tidak mempunyai permit yang sah.", + 'combined_text': "Menurut sumber, kelongsong tersebut adalah untuk dikitar semula dan bukan untuk kegunaan senjata aktif. Namun, ia tetap menyalahi undang-undang kerana tidak mempunyai permit yang sah." + }) + + elif any(term in claim_text.lower() for term in ['minyak sawit', 'cukai', 'ekonomi']): + # Palm oil tax related claim + sample_data.append({ + 'platform': 'LowyatForum', + 'date': current_date, + 'username': 'EconomyWatcher', + 'post_text': f"Adakah benar kerajaan akan mengenakan cukai khas terhadap minyak sawit mentah? Ini akan memberi kesan besar kepada industri dan ekonomi negara.", + 'post_url': 'https://forum.lowyat.net/topic/palm-oil-tax', + 'likes': 20, + 'shares': 8, + 'comments_count': 12, + 'comment_text': '', + 'combined_text': f"Adakah benar kerajaan akan mengenakan cukai khas terhadap minyak sawit mentah? Ini akan memberi kesan besar kepada industri dan ekonomi negara." + }) + + sample_data.append({ + 'platform': 'LowyatForum_Comment', + 'date': current_date, + 'username': 'IndustryInsider', + 'post_text': '', + 'post_url': 'https://forum.lowyat.net/topic/palm-oil-tax#comment1', + 'likes': 15, + 'shares': 0, + 'comments_count': 0, + 'comment_text': "Menurut sumber dari kementerian, cadangan cukai ini masih dalam peringkat kajian dan belum ada keputusan muktamad. Namun, jika dilaksanakan, ia akan memberi kesan kepada harga minyak masak.", + 'combined_text': "Menurut sumber dari kementerian, cadangan cukai ini masih dalam peringkat kajian dan belum ada keputusan muktamad. Namun, jika dilaksanakan, ia akan memberi kesan kepada harga minyak masak." + }) + + else: + # Default generic sample data if no specific claim type is detected + sample_data.append({ + 'platform': 'LowyatForum', + 'date': current_date, + 'username': 'LowyatUser123', + 'post_text': f"Discussing: {claim_text}", + 'post_url': 'https://forum.lowyat.net/topic/sample', + 'likes': 5, + 'shares': 0, + 'comments_count': 2, + 'comment_text': '', + 'combined_text': f"Discussing: {claim_text}" + }) + + sample_data.append({ + 'platform': 'LowyatForum_Comment', + 'date': current_date, + 'username': 'LowyatCommenter', + 'post_text': '', + 'post_url': 'https://forum.lowyat.net/topic/sample#comment1', + 'likes': 2, + 'shares': 0, + 'comments_count': 0, + 'comment_text': f"Commenting on: {claim_text}", + 'combined_text': f"Commenting on: {claim_text}" + }) + + # If no sample data was created (unlikely), create a default one + if not sample_data: + sample_data.append({ + 'platform': 'LowyatForum', + 'date': current_date, + 'username': 'LowyatUser123', + 'post_text': f"Discussing: {claim_text}", + 'post_url': 'https://forum.lowyat.net/topic/sample', + 'likes': 5, + 'shares': 0, + 'comments_count': 2, + 'comment_text': '', + 'combined_text': f"Discussing: {claim_text}" + }) + + sample_df = pd.DataFrame(sample_data) + if lowyat_output_path: + sample_df.to_csv(lowyat_output_path, index=False) + + all_records.extend(sample_data) + print(f"[📚] Added {len(sample_data)} sample Lowyat Forum records") + except Exception as e: + print(f"[⚠️] Error during Lowyat Forum crawling: {str(e)}") + print("[⚠️] Continuing without Lowyat Forum data...") + + except ImportError: + print("[❌] Lowyat Forum crawler module not found. Skipping Lowyat Forum data collection.") + + except Exception as e: + print(f"[❌] Error during Lowyat Forum data collection: {str(e)}") + print("[⚠️] Continuing with other data sources...") + + # Save all records to CSV + if all_records: + df = pd.DataFrame(all_records) + df.to_csv(output_path, index=False) + print(f"[💾] Saved {len(df)} records to {output_path}") + + # Print summary of data sources + source_counts = df['platform'].value_counts().to_dict() + print("\n[📊] Data collection summary:") + for source, count in source_counts.items(): + # Use shorter display names for Lowyat Forum sources + display_source = source + if source == "LowyatForum": + display_source = "LF" + elif source == "LowyatForum_Comment": + display_source = "LF_Comment" + print(f" - {display_source}: {count} records") + + return df + else: + # Create empty DataFrame and save to CSV + empty_df = pd.DataFrame(columns=["platform", "date", "username", "post_text", "post_url", "likes", "shares", "comments_count", "comment_text", "combined_text"]) + empty_df.to_csv(output_path, index=False) + print(f"[⚠️] No records found. Saved empty DataFrame to {output_path}") + return empty_df + +def run_actor_task(task_id, input_payload, platform="facebook", timeout=30, max_retries=3, use_cache=True, cache_ttl_hours=24): + # Generate a cache key based on task_id and input_payload + cache_key = f"{task_id}_{json.dumps(input_payload, sort_keys=True)}" + cache_hash = hashlib.md5(cache_key.encode()).hexdigest() + cache_file = os.path.join(CACHE_DIR, f"{cache_hash}.json") + + # Check if we have a valid cached result + if use_cache and os.path.exists(cache_file): + try: + with open(cache_file, 'r') as f: + cache_data = json.load(f) + + # Check if cache is still valid + cache_time = datetime.fromisoformat(cache_data.get('timestamp')) + cache_expiry = cache_time + timedelta(hours=cache_ttl_hours) + + if datetime.now() < cache_expiry: + print(f"[💾] Using cached result for task {task_id} (expires {cache_expiry.isoformat()})") + return cache_data.get('dataset_id') + else: + print(f"[⏰] Cache expired for task {task_id}, fetching fresh data") + except Exception as e: + print(f"[⚠️] Error reading cache: {str(e)}") + + token = APIFY_TOKEN_FB if platform == "facebook" else APIFY_TOKEN_TIKTOK + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + url = f"https://api.apify.com/v2/actor-tasks/{task_id}/runs" + + # Try multiple times in case of network issues + for attempt in range(max_retries): + try: + print(f"[🔄] Attempt {attempt+1}/{max_retries} to run task {task_id}...") + print(input_payload) + # response = requests.post(url, json={"input": input_payload}, headers=headers, timeout=timeout) + response = requests.post(url, json=input_payload, headers=headers, timeout=timeout) + + if response.status_code != 201: + print(f"[❌] Failed to run task: {response.text}") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + continue + raise Exception(f"Task run failed after {max_retries} attempts.") + + run_id = response.json()["data"]["id"] + print(f"[🟢] Task {task_id} started: {run_id}") + status_url = f"https://api.apify.com/v2/actor-runs/{run_id}" + break # Success, exit the retry loop + except requests.exceptions.Timeout: + print(f"[❌] Request timed out after {timeout} seconds") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + else: + raise Exception(f"Task run timed out after {max_retries} attempts.") + except requests.exceptions.ConnectionError: + print(f"[❌] Connection error") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + else: + raise Exception(f"Connection error after {max_retries} attempts.") + except Exception as e: + print(f"[❌] Unexpected error: {str(e)}") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + else: + raise Exception(f"Unexpected error after {max_retries} attempts: {str(e)}") + while True: + status_data = requests.get(status_url, headers=headers).json() + if status_data["data"]["status"] in ["SUCCEEDED", "FAILED"]: + break + print("[⏳] Waiting for task run to complete...") + time.sleep(5) + + if status_data["data"]["status"] == "SUCCEEDED": + dataset_id = status_data["data"]["defaultDatasetId"] + + # Save result to cache + if use_cache: + try: + cache_data = { + "dataset_id": dataset_id, + "timestamp": datetime.now().isoformat(), + "task_id": task_id, + "platform": platform + } + + with open(cache_file, 'w') as f: + json.dump(cache_data, f) + + print(f"[💾] Saved result to cache: {cache_file}") + except Exception as e: + print(f"[⚠️] Error saving to cache: {str(e)}") + + return dataset_id + else: + raise Exception("Task run failed.") + +def is_malaysian_content(username, text): + # Check if content is relevant to the claim + user_lower = (username or "").lower() + text_lower = (text or "").lower() + + # Get the full claim from environment if available + full_claim = os.environ.get("FULL_CLAIM", "") + claim_lower = full_claim.lower() + + # Check if this is about sexual crimes in Kelantan + kelantan_sexual_crime = "kelantan" in claim_lower and ("rogol" in claim_lower or "sumbang mahram" in claim_lower) + + if kelantan_sexual_crime: + # For the specific claim about sexual crimes in Kelantan, use very targeted filtering + kelantan_keywords = ["kelantan", "kelantanese"] + crime_keywords = ["rogol", "sumbang mahram", "jenayah seksual", "kes", "polis", "pdrm"] + + # Must have at least one Kelantan reference AND one crime reference to be relevant + has_kelantan_ref = any(k in text_lower for k in kelantan_keywords) + has_crime_ref = any(k in text_lower for k in crime_keywords) + + if has_kelantan_ref and has_crime_ref: + return True + + # Check if username is from a relevant authority + authority_users = ["polis", "pdrm", "kelantan", "bukit aman", "bernama", "berita"] + if any(k in user_lower for k in authority_users): + return True + + # More restrictive for this specific claim - return False if not matching criteria + return False + else: + # General Malaysian content detection for other claims + # Keywords for crime-related content + crime_keywords = [ + "polis", "kelantan", "jenayah", "rogol", "sumbang mahram", "inses", + "kes", "statistik", "bimbang", "pdrm", "malaysia", "undang-undang", + "mahkamah", "hukuman", "tangkap", "siasat", "lapor", "mangsa", "suspek", + "tertuduh", "penderaan", "seksual", "cabul", "gangguan" + ] + + # Check if any crime keywords are in the text + if any(k in text_lower for k in crime_keywords): + return True + + # Check if username looks Malaysian + malaysian_user_indicators = [ + "my", "ms", "malaysia", "officialmy", "rakyat", "malay", + "dr", "dato", "yb", "ustaz", "cikgu", "polis", "kelantan" + ] + + if any(k in user_lower for k in malaysian_user_indicators): + return True + + # Default to True for now to maximize data collection, but with better filtering + return True + + + +def download_dataset(dataset_id, platform="facebook", timeout=30, max_retries=3, use_cache=True, cache_ttl_hours=24): + # Check if we have a cached dataset + cache_file = os.path.join(CACHE_DIR, f"dataset_{dataset_id}.json") + + if use_cache and os.path.exists(cache_file): + try: + with open(cache_file, 'r') as f: + cache_data = json.load(f) + + # Check if cache is still valid + cache_time = datetime.fromisoformat(cache_data.get('timestamp')) + cache_expiry = cache_time + timedelta(hours=cache_ttl_hours) + + if datetime.now() < cache_expiry: + print(f"[💾] Using cached dataset {dataset_id} (expires {cache_expiry.isoformat()})") + return cache_data.get('data', []) + else: + print(f"[⏰] Cache expired for dataset {dataset_id}, fetching fresh data") + except Exception as e: + print(f"[⚠️] Error reading dataset cache: {str(e)}") + + token = APIFY_TOKEN_FB if platform == "facebook" else APIFY_TOKEN_TIKTOK + headers = { + "Authorization": f"Bearer {token}" + } + dataset_url = f"https://api.apify.com/v2/datasets/{dataset_id}/items?clean=true&format=json" + + # Try multiple times in case of network issues + for attempt in range(max_retries): + try: + print(f"[🔄] Attempt {attempt+1}/{max_retries} to download dataset {dataset_id}...") + response = requests.get(dataset_url, headers=headers, timeout=timeout) + + if response.status_code != 200: + print(f"[❌] Failed to download dataset: {response.text}") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + continue + raise Exception(f"Dataset download failed after {max_retries} attempts.") + + data = response.json() + print(f"[✓] Downloaded {len(data)} items from dataset {dataset_id}") + + # Save dataset to cache + if use_cache: + try: + cache_data = { + "data": data, + "timestamp": datetime.now().isoformat(), + "dataset_id": dataset_id, + "platform": platform + } + + with open(cache_file, 'w') as f: + json.dump(cache_data, f) + + print(f"[💾] Saved dataset to cache: {cache_file}") + except Exception as e: + print(f"[⚠️] Error saving dataset to cache: {str(e)}") + + return data + except requests.exceptions.Timeout: + print(f"[❌] Request timed out after {timeout} seconds") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + else: + raise Exception(f"Dataset download timed out after {max_retries} attempts.") + except requests.exceptions.ConnectionError: + print(f"[❌] Connection error") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + else: + raise Exception(f"Connection error after {max_retries} attempts.") + except Exception as e: + print(f"[❌] Unexpected error: {str(e)}") + if attempt < max_retries - 1: + print("[⏳] Retrying...") + time.sleep(5) # Wait 5 seconds before retrying + else: + raise Exception(f"Unexpected error after {max_retries} attempts: {str(e)}") + + # If we get here, all retries failed + return [] + +def build_boolean_search(keywords): + """Build an optimized search query for social media platforms""" + search_terms = [] + + for kw in keywords: + # If keyword contains spaces (multi-word phrase), wrap in quotes + if " " in kw: + search_terms.append(f'"{kw}"') + else: + # For single words, don't use quotes to get broader results + search_terms.append(kw) + + return " OR ".join(search_terms) + diff --git a/ai_api/library/config.py b/ai_api/library/config.py new file mode 100644 index 0000000000000000000000000000000000000000..ec1c0acc626cfb9b6fd5e65b6ffd8f1608bd28fe --- /dev/null +++ b/ai_api/library/config.py @@ -0,0 +1,131 @@ +""" +config.py +Central configuration for the claim analysis system +""" + +import os + +# Base directories +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +OUTPUT_DIR = os.path.join(BASE_DIR, "output") +REPORTS_DIR = os.path.join(BASE_DIR, "reports") + +# Create directories if they don't exist +for directory in [DATA_DIR, OUTPUT_DIR, REPORTS_DIR]: + os.makedirs(directory, exist_ok=True) + +# API Keys +GOOGLE_API_KEY = "AIzaSyAnXTkB_0HKXKul3eI-1A56ZQWyjTVj1cQ" # Google Custom Search API key +GOOGLE_SEARCH_ENGINE_ID = "e7e6c19ee7a984f30" # Add your search engine ID here (you'll need to create this) + +# Serper.dev API Key (alternative search API) +SERPER_API_KEY = "e0af440fd71fb125dd38644fe378831c3ed741ca" + +# SerpApi Google Search API Key +SERPAPI_API_KEY = "007928aeb7d86d4a85af12728e3534163961837027afb63ec7b89a4624a9f4ac" + +# Data source settings +USE_FACEBOOK = False # Disable Facebook data collection +USE_TIKTOK = True # Enable TikTok data collection +USE_SERPAPI = True # Enable SerpApi web search +USE_SERPER = True # Enable Serper.dev web search +USE_DUCKDUCKGO = False # Disable DuckDuckGo web search +USE_LOWYAT = True # Enable Lowyat Forum data collection + +# Number of results to collect from each source +FACEBOOK_MAX_RESULTS = 100 +TIKTOK_MAX_RESULTS = 10 # Significantly reduced to save Apify costs +WEB_SEARCH_MAX_RESULTS = 20 +LOWYAT_MAX_THREADS = 20 # Maximum number of Lowyat Forum threads to collect + +# Lowyat Forum settings +LOWYAT_SECTIONS = [ + "Kopitiam", "SeriousKopitiam", "News", "Politics", "Malaysia", "Lowyat.NET", + "Technology", "Computers", "Notebooks", "Smartphones", "Photography", "GamingPC", "GamingConsole", + "Automotive", "Finance", "Property", "Travel", "Food", "Health", "Sports", "Entertainment", + "SpecialInterestGarageSales", "JobsCorner", "DigitalMarketplace" +] # All available forum sections + +# Social Media API tokens +APIFY_TOKEN = "apify_api_INtF6uUT4c6nOStYDYTllxuTBNSbng1IlTTB" # Main Apify API token +APIFY_TOKEN_FB = APIFY_TOKEN # For Facebook actors +APIFY_TOKEN_TIKTOK = APIFY_TOKEN # For TikTok actors + +# Actor task IDs +# From danek/facebook-search-ppr +POST_TASK_ID_SEARCH = "l5DitJrtfCyOfrjn6" # Facebook Search PPR (rajamohd/facebook-search-ppr-rm-bernama) + +# From datavoyantlab/facebook-comments-scraper +COMMENT_TASK_ID = "qiAp6PQwkyYcLQiyC" # Facebook Comments Scraper (rajamohd/facebook-comments-scraper-task) + +# From clockworks/free-tiktok-scraper +TIKTOK_VIDEO_TASK_ID = "rfk0BzRAjuLPbccaZ" # TikTok Data Extractor (devlab/tiktok-data-extractor-bernama2-video) + +# From clockworks/tiktok-comments-scraper +TIKTOK_COMMENT_TASK_ID = "rgXeWIhnXKRD5bjGp" # TikTok Comments Scraper (devlab/tiktok-comments-scraper-bernama2) + +# Apify settings +USE_COMMENTS = True # Whether to collect comments in addition to posts/videos + +# Sentiment model +SENTIMENT_MODEL = "rmtariq/ft-Malay-bert" + +# Priority indexer settings +PRIORITY_WEIGHTS = { + "fact_check_value": 1.5, # Higher weight for factual importance + "cause_confusion": 1.2, # Medium-high weight for confusion potential + "cause_chaos": 1.8, # High weight for potential harm + "affects_government": 1.3, # Medium-high for government impact + "economic_impact": 1.4, # Medium-high for economic impact + "law_related": 1.5, # Higher weight for legal implications + "public_interest": 1.2, # Medium weight for public interest + "lives_in_danger": 2.0, # Highest weight for safety concerns + "viral": 1.1, # Lower weight for virality alone + "urgent": 1.3 # Medium-high for urgency +} + +PRIORITY_THRESHOLDS = { + "high_priority": 7.0, + "medium_priority": 5.0, + "low_priority": 3.0 +} + +# Classification settings +VERDICT_CATEGORIES = { + "TIDAK_BENAR": { + "name": "TIDAK BENAR", + "description": "Dakwaan ini tidak benar berdasarkan bukti yang ada.", + "threshold": 7.0, + "conditions": ["fact_check_value", "law_related"] + }, + "BERCAMPUR": { + "name": "BERCAMPUR", + "description": "Dakwaan ini mengandungi unsur-unsur benar dan tidak benar.", + "threshold": 5.0, + "conditions": ["cause_confusion"] + }, + "BENAR": { + "name": "BENAR", + "description": "Dakwaan ini benar berdasarkan bukti yang ada.", + "threshold": 3.0, + "conditions": [] + }, + "TIDAK_PASTI": { + "name": "TIDAK PASTI", + "description": "Tidak cukup bukti untuk menentukan kebenaran dakwaan ini.", + "threshold": 0.0, + "conditions": [] + } +} + +# Database settings +DB_PATH = os.path.join(DATA_DIR, "claims.db") + +# Malaysian filter settings +MALAYSIAN_FILTER_THRESHOLD = 0.5 # Confidence threshold for Malaysian content + +# Report settings +REPORT_TEMPLATE = None # Path to DOCX template (optional) +GOOGLE_SEARCH_ENGINE_ID = "e7e6c19ee7a984f30" # Google Search Engine ID + diff --git a/ai_api/library/devlab_image.py b/ai_api/library/devlab_image.py new file mode 100644 index 0000000000000000000000000000000000000000..8a7c36ae511fd6f20e08391a2423ca551d782833 --- /dev/null +++ b/ai_api/library/devlab_image.py @@ -0,0 +1,487 @@ +import os +from transformers import BlipProcessor, BlipForConditionalGeneration +from PIL import Image +from PIL.ExifTags import TAGS +import json +import subprocess +from transformers import CLIPProcessor, CLIPModel +import torch +import requests +import base64 +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.chrome.options import Options +from webdriver_manager.chrome import ChromeDriverManager +from bs4 import BeautifulSoup +import urllib.parse +import time +from deepface import DeepFace +from pymilvus import Collection, connections, CollectionSchema, FieldSchema, DataType +import numpy as np +# import faiss +import os +import pickle +import pprint +import cv2 +from dotenv import load_dotenv +load_dotenv() + + +milvus_host = os.getenv("MILVUS_HOST", "localhost") # default localhost +milvus_port = os.getenv("MILVUS_PORT", "19530") # default 19530 + +connections.connect("default", host=milvus_host, port=int(milvus_port)) + + + + +blip_processor = BlipProcessor.from_pretrained("Salesforce/blip-image-captioning-base") +blip_model = BlipForConditionalGeneration.from_pretrained("Salesforce/blip-image-captioning-base") +clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32") +clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") + + +ES_HOST = "https://localhost:9200" +ES_USER = "elastic" +ES_PASS = "qR_BblnAzT-1pOQgFRvZ" +ES_INDEX = "faces" + +class DevLabImage : + + def __init__(self, image_path = None): + self.image_path = image_path + + def sanitize_name(self, title, replace ='_'): + import re + title = re.sub(r'\s+', ' ', title).strip() + return re.sub(r'[\\/*?:"<>|]', replace, title) + + + def extract_text(self, image_path): + import easyocr + reader = easyocr.Reader(["en", "ms"]) # English & Malay + text = reader.readtext(image_path, detail=0) + return " ".join(text) + + def extract_text_numpy(self, np_array): + import easyocr + reader = easyocr.Reader(["en", "ms"]) # English & Malay + text = reader.readtext(np_array, detail=0) + return text + + # def get_emotions(self): + # from deepface import DeepFace + # return DeepFace.analyze(self.image_path, actions=['emotion']) + + def extract_exif(self, image_path): + """Extract EXIF metadata from an image""" + + image = Image.open(image_path) + exif_data = image._getexif() + + metadata = {} + if exif_data: + for tag, value in exif_data.items(): + tag_name = TAGS.get(tag, tag) + metadata[tag_name] = value + + return metadata + + def extract_metadata_exiftool(self,image_path): + """Extract IPTC, XMP, and EXIF metadata using ExifTool""" + + command = ["exiftool", "-j", image_path] + result = subprocess.run(command, capture_output=True, text=True) + metadata = json.loads(result.stdout)[0] if result.stdout else {} + + return metadata + + + def generate_description_blip(self, image_path): + """Generate an image description using BLIP""" + + image = Image.open(image_path).convert("RGB") + inputs = blip_processor(image, return_tensors="pt") + out = blip_model.generate(**inputs) + return blip_processor.decode(out[0], skip_special_tokens=True) + + def extract_image_features(self,image_path): + """Extract image embeddings using CLIP""" + + + image = Image.open(image_path) + inputs = clip_processor(images=image, return_tensors="pt") + with torch.no_grad(): + features = clip_model.get_image_features(**inputs) + return features.squeeze().numpy() + + # def download_google(self,arguments): + # """Download from Google""" + # response = google_images_download.googleimagesdownload() + # response.download(arguments) + + + # def download_person(self,person_name): + # # Define the emotions to search + # emotions = ["happy", "sad", "angry", "surprised"] + + # for emotion in emotions: + # arguments = { + # "keywords": f"{person_name} {emotion} face", + # "limit": 10, # Download 10 images per emotion + # "print_urls": True, + # "format": "jpg", + # "output_directory": "people", + # "image_directory": self.sanitize_name(person_name, ' ') # Save into separate folders per emotion + # } + # self.download_google(arguments) + + def download_image(self, url, folder, image_name): + """Download and save the image.""" + + try: + if url.startswith("data:image/"): # Base64 encoded image + header, encoded_data = url.split(",", 1) + extension = header.split(";")[0].split("/")[-1] # Extract file type (jpg, png, etc.) + image_path = os.path.join(folder, f"{image_name}.{extension}") + + os.makedirs(folder, exist_ok=True) + with open(image_path, "wb") as file: + file.write(base64.b64decode(encoded_data)) + + print(f"✅ Base64 image saved: {image_path}") + + else: # URL download + + response = requests.get(url, stream=True, timeout=10) + if response.status_code == 200: + os.makedirs(folder, exist_ok=True) + image_path = os.path.join(folder, f"{image_name}.jpg") + with open(image_path, "wb") as file: + for chunk in response.iter_content(1024): + file.write(chunk) + print(f"✅ Downloaded: {image_path}") + else: + print(f"❌ Failed to download: {url}") + except Exception as e: + print(f"⚠ Error downloading {url}: {e}") + + def has_min_img_size(self, tag, min_size=100): + img = tag.find("img") + if img and img.has_attr("width") and img.has_attr("height"): + try: + width = int(img["width"]) + height = int(img["height"]) + return width >= min_size and height >= min_size + except ValueError: + return False + return False + + def search_google_images(self, query, num_images=10): + + # Set up Chrome WebDriver + options = Options() + options.binary_location = "/usr/bin/chromium" # important for Docker + options.add_argument("--headless") # Run in background + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-gpu") + options.add_argument("--window-size=1920x1080") + + # Create driver using installed chromedriver + driver = webdriver.Chrome( + service=Service("/usr/bin/chromedriver"), # use system-installed path + options=options + ) + + + """Search Google Images and extract image URLs.""" + encoded_query = urllib.parse.quote(query) + search_url = f"https://www.google.com/search?q={encoded_query}&tbm=isch&sclient=img" + + print(f"🔍 Searching for: {query}") + + driver.get(search_url) + time.sleep(2) # Wait for page to load + + list_items = driver.find_elements(By.CSS_SELECTOR, "div[role='listitem']") + list_items[1].click() + time.sleep(3) # Wait for page to load + + # Scroll to load more images + for _ in range(3): + driver.find_element(By.TAG_NAME, "body").send_keys(Keys.END) + time.sleep(2) + + # Extract image URLs + soup = BeautifulSoup(driver.page_source, "html.parser") + + # target_div = soup.find("div", {"id":query}) + + # # Extract all tags inside the div + # if target_div: + # images = target_div.find_all("img") + # # images = soup.find_all("img") + # else: + # images = soup.select("g-img img") + # g_imgs = [g for g in soup.find_all("g-img") if g.get("style") not in ("width:12px;height:12px", "width:46px;height:46px")] + g_imgs = [g for g in soup.find_all("g-img") if self.has_min_img_size(g)] + + + # g_imgs = soup.select("g-img") + + # print(g_imgs) + # driver.quit() + # return + + image_urls = [] + for gimg in g_imgs: + if len(image_urls) >= num_images: + break + img = gimg.find('img') + src = img.get("src") + + if src.startswith("data:image/"): + mime_type = src.split(";")[0].split(":")[1] # Extract MIME type + file_extension = mime_type.split("/")[-1] # Extract file extension + else: + file_extension = src.split(".")[-1].split("?")[0].lower() # Extract file extension from URL + + # Skip GIFs + if file_extension == "gif": + continue + # if not src or not src.startswith("data:image/"): + # continue + + # mime_type = src.split(";")[0].split(":")[1] + # file_extension = mime_type.split("/")[-1] + # if file_extension == "gif": + # continue + + image_urls.append(src) + + print(f"✅ Found {len(image_urls)} images for {query}") + driver.quit() + return image_urls + + def download_person_images(self, person_name, tags = None): + """Download images for a person with different emotions.""" + emotions = ["happy", "sad", "angry", "surprised"] + foldername = self.sanitize_name(person_name, ' ') + # filename = self.sanitize_name(person_name) + # for emotion in emotions: + # folder = f"people/{foldername}" + # image_urls = self.search_google_images(person_name, emotion) + + # for i, url in enumerate(image_urls): + # self.download_image(url, folder, f"{emotion}{i+1}") + + folder = f"people/{foldername}" + # query = f"{person_name} headshot OR close-up HD -group -friends -couple -family -crowd -far -selfie {tags}" + # query = f"'{person_name}' headshot OR close-up HD medium size {tags}" + # query = f"'{person_name}' official portrait large size" + query = f"'{person_name}' portrait {tags}" + + image_urls = self.search_google_images(query, 5) + for i, url in enumerate(image_urls): + self.download_image(url, folder, f"{i+1}") + + return foldername + + def extract_face(self, person, tags): + + try: + collection = Collection("faces") + collection.load() # Try loading the collection to check if it exists + print("Collection 'faces' already exists.") + except Exception as e: + # If collection doesn't exist, create it + print(f"Creating collection: {e}") + fields = [ + FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), + FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=128), + FieldSchema(name="name", dtype=DataType.VARCHAR, max_length=255), + FieldSchema(name="short_description", dtype=DataType.VARCHAR, max_length=255), + FieldSchema(name="description", dtype=DataType.VARCHAR, max_length=5000), + ] + schema = CollectionSchema(fields, description="Face embeddings") + collection = Collection(name="faces", schema=schema) + collection.create_index(field_name="embedding", index_params={"metric_type": "COSINE", "index_type": "HNSW", "params": {"M": 32, "efConstruction": 512}}) + collection.load() + + dataset_path = "people/" + person_path = os.path.join(dataset_path, person) + print(person_path) + + if not os.path.isdir(person_path): + return + + image_files = [f for f in os.listdir(person_path) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] + + for img in image_files: + img_path = os.path.join(person_path, img) + try: + embedding = self.extract_embedding(image_path=img_path) + if embedding is not None: + emb = np.array(embedding, dtype=np.float32) + if emb.size > 0: + collection.insert([[emb], [person], [tags], ['']]) + print(f"{person} registered") + else: + print(f"No embedding found for {img_path}") + + except Exception as e: + print(f"Could not process {img_path}: {str(e)}") + + def register_person(self, person_name, tags = ''): + """Register a person with their images.""" + folder = self.download_person_images(person_name, tags) + self.extract_face(folder,tags) + + def query_embedding(self,query_embedding, top_k=5): + + # Load the collection + try: + collection = Collection("faces") + collection.load() # Try loading the collection to check if it exists + print("Collection 'faces' already exists.") + except Exception as e: + # If collection doesn't exist, create it + print(f"Creating collection: {e}") + fields = [ + FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), + FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=128), + FieldSchema(name="name", dtype=DataType.VARCHAR, max_length=255), + FieldSchema(name="short_description", dtype=DataType.VARCHAR, max_length=255), + FieldSchema(name="description", dtype=DataType.VARCHAR, max_length=5000), + ] + schema = CollectionSchema(fields, description="Face embeddings") + collection = Collection(name="faces", schema=schema) + collection.create_index(field_name="embedding", index_params={"metric_type": "COSINE", "index_type": "HNSW", "params": {"M": 32, "efConstruction": 512}}) + collection.load() + + # query_embedding = self.extract_embedding(query_image_path) + # if query_embedding is None: + # print("No embedding extracted for the query image.") + # return None + + # Convert the query embedding to a numpy array + query_emb = np.array(query_embedding, dtype=np.float32).reshape(1, -1) + params = {"metric_type": "COSINE", "params": {"efTopK": top_k}} + + search_results = collection.search(query_emb, "embedding", output_fields=["id", "name","short_description","description"], param=params, limit=top_k) + + return search_results + + + def extract_embedding(self, image_path): + try: + faces = DeepFace.represent(image_path, model_name="Facenet", enforce_detection=False) + + if faces: + return faces[0]["embedding"] + else: + return None + + except Exception as e: + print(f"Failed on {image_path}: {e}") + return None + + def detect_faces(self): + + image = cv2.imread(self.image_path) + + face_embeddings = DeepFace.represent(self.image_path, model_name="Facenet", enforce_detection=False) + + if not face_embeddings: # No faces detected + return "❌ No faces detected in the image." + + recognized_faces = {} + + for face_data in face_embeddings: + # print(face_data) + face_embedding = np.array(face_data["embedding"]).tolist() + + face_location = face_data["facial_area"] + # face_location = face_data["region"] + + x, y, w, h = face_location["x"], face_location["y"], face_location["w"], face_location["h"] + clipped_face = image[y:y+h, x:x+w] + + # The search query using cosine similarity + query = { + "size": 1, + "query": { + "script_score": { + "query": {"match_all": {}}, # Match all documents + "script": { + "source": "(cosineSimilarity(params.query_vector, 'embedding') + 1) / 2", # Cosine similarity formula + "params": { + "query_vector": face_embedding # The face embedding you want to compare + } + } + } + } + } + + # Perform the POST request to Elasticsearch + response = requests.post( + f"{ES_HOST}/{ES_INDEX}/_search", + headers={"Content-Type": "application/json"}, + auth=(ES_USER, ES_PASS), + json=query, + verify=False # Disable SSL verification for testing (in production, use SSL) + ) + + # Check if the request was successful + if response.status_code == 200: + # return response.json() + results = response.json() + # pprint.pprint(results) + if results['hits']['hits']: + name = results['hits']['hits'][0]['_source']['name'] + recognized_faces[f"clip_{len(recognized_faces) + 1}"] = {"name": name, "image": clipped_face, "score": results['hits']['hits'][0]['_score']} + + + return recognized_faces + + def delete_person(self, person): + import requests + import json + + delete_query = { + "query": { + "term": { + "name": person # Field to match and its value + } + } + } + + # Send the DELETE request to Elasticsearch + response = requests.post( + f"{ES_HOST}/{ES_INDEX}/_delete_by_query", + auth=(ES_USER, ES_PASS), + headers={"Content-Type": "application/json"}, + data=json.dumps(delete_query), + verify=False # Disable SSL verification for testing (use True in production) + ) + + # Check if the request was successful + if response.status_code == 200: + print(f"Documents with name = {person} deleted successfully.") + + + def analyze(self): + analysis = DeepFace.analyze(self.image_path, actions= ['age', 'gender', 'race', 'emotion']) + return analysis[0] + + def reverse_search(self, image_path): + from reverse_image_search import reverse_image_search + + return reverse_image_search(image_path, engines=["google", "yandex"]) + + + + \ No newline at end of file diff --git a/ai_api/library/lowyat_crawler.py b/ai_api/library/lowyat_crawler.py new file mode 100644 index 0000000000000000000000000000000000000000..4fd0cb4366cc1d66be772a53bcbbb80ed0d9f9a8 --- /dev/null +++ b/ai_api/library/lowyat_crawler.py @@ -0,0 +1,714 @@ +# lowyat_crawler.py +# Crawler for Lowyat Forum data + +import requests +from bs4 import BeautifulSoup +import pandas as pd +import time +import random +import os +import json +import hashlib +from datetime import datetime, timedelta +import re + +# Create cache directory +CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache") +os.makedirs(CACHE_DIR, exist_ok=True) + +# Lowyat Forum base URL +LOWYAT_BASE_URL = "https://forum.lowyat.net" + +# Forum section IDs +FORUM_SECTIONS = { + # Main Discussion Forums + "Kopitiam": "16", # General discussion + "SeriousKopitiam": "506", # Serious discussions + "News": "17", # News discussions + "Politics": "507", # Political discussions + "Malaysia": "508", # Malaysia-specific topics + "Lowyat.NET": "18", # Lowyat.NET related discussions + + # Technology Forums + "Technology": "19", # Technology discussions + "Computers": "20", # Computer discussions + "Notebooks": "32", # Laptop discussions + "Smartphones": "22", # Smartphone discussions + "Photography": "29", # Photography discussions + "GamingPC": "503", # PC Gaming + "GamingConsole": "504", # Console Gaming + + # Lifestyle Forums + "Automotive": "23", # Car and motorcycle discussions + "Finance": "24", # Financial discussions + "Property": "25", # Property discussions + "Travel": "26", # Travel discussions + "Food": "27", # Food discussions + "Health": "28", # Health discussions + "Sports": "30", # Sports discussions + "Entertainment": "31", # Entertainment discussions + + # Marketplace Forums + "SpecialInterestGarageSales": "21", # Buy and sell + "JobsCorner": "33", # Job listings + "DigitalMarketplace": "34" # Digital marketplace +} + +def get_forum_section_url(section_name): + """Get the URL for a forum section""" + if section_name in FORUM_SECTIONS: + section_id = FORUM_SECTIONS[section_name] + return f"{LOWYAT_BASE_URL}/forums/{section_id}" + else: + # Assume it's a custom section name, try to search for it + return f"{LOWYAT_BASE_URL}/search/forums?q={section_name}" + +def clean_text(text): + """Clean text by removing extra whitespace""" + if not text: + return "" + return re.sub(r'\s+', ' ', text).strip() + +def extract_date(date_str): + """Extract and standardize date from Lowyat Forum date string""" + try: + # Handle various date formats + if "Today" in date_str or "Yesterday" in date_str: + # For relative dates, convert to actual date + today = datetime.now().date() + if "Yesterday" in date_str: + date = today - timedelta(days=1) + else: + date = today + + # Extract time if available + time_match = re.search(r'(\d+:\d+\s*[AP]M)', date_str) + if time_match: + time_str = time_match.group(1) + return f"{date.isoformat()} {time_str}" + return date.isoformat() + else: + # Try to parse standard date formats + date_patterns = [ + r'(\d{1,2}-\d{1,2}-\d{4})', # DD-MM-YYYY + r'(\d{1,2}/\d{1,2}/\d{4})', # DD/MM/YYYY + r'(\w+ \d{1,2}, \d{4})' # Month DD, YYYY + ] + + for pattern in date_patterns: + match = re.search(pattern, date_str) + if match: + return match.group(1) + + # If no pattern matches, return the original string + return date_str + except Exception as e: + print(f"Error parsing date '{date_str}': {str(e)}") + return date_str + +def search_lowyat_forum(keywords, sections=None, max_pages=3, max_threads=20, use_cache=True, cache_ttl_hours=24, verbose=True, use_mock_data=True): + """ + Search Lowyat Forum for threads matching keywords + + Args: + keywords (list): List of keywords to search for + sections (list): List of forum sections to search in (default: ["Kopitiam", "SeriousKopitiam", "Finance"]) + max_pages (int): Maximum number of search result pages to process + max_threads (int): Maximum number of threads to process + use_cache (bool): Whether to use cached results + cache_ttl_hours (int): How long to keep cached results valid + verbose (bool): Whether to print verbose output + use_mock_data (bool): Whether to use mock data if real data cannot be retrieved + + Returns: + list: List of thread data dictionaries + """ + if sections is None: + sections = ["Kopitiam", "SeriousKopitiam", "Finance"] + + # Generate cache key + cache_key = f"lowyat_{'_'.join(keywords)}_{'_'.join(sections)}_{max_pages}_{max_threads}" + cache_hash = hashlib.md5(cache_key.encode()).hexdigest() + cache_file = os.path.join(CACHE_DIR, f"lowyat_{cache_hash}.json") + + # Check cache + if use_cache and os.path.exists(cache_file): + try: + with open(cache_file, 'r') as f: + cache_data = json.load(f) + + # Check if cache is still valid + cache_time = datetime.fromisoformat(cache_data.get('timestamp')) + cache_expiry = cache_time + timedelta(hours=cache_ttl_hours) + + if datetime.now() < cache_expiry: + print(f"[💾] Using cached Lowyat Forum results (expires {cache_expiry.isoformat()})") + return cache_data.get('threads', []) + else: + print(f"[⏰] Cache expired for Lowyat Forum search, fetching fresh data") + except Exception as e: + print(f"[⚠️] Error reading Lowyat Forum cache: {str(e)}") + + all_threads = [] + threads_processed = 0 + cloudflare_detected = False + + # Process each section + for section in sections: + if threads_processed >= max_threads: + break + + print(f"[🔍] Searching Lowyat Forum section: {section}") + section_url = get_forum_section_url(section) + + # For each keyword, search the section + for keyword in keywords: + if threads_processed >= max_threads: + break + + print(f"[🔍] Searching for keyword: {keyword}") + + # Construct search URL + if "search" in section_url: + # Already a search URL, add the keyword + search_url = f"{section_url}+{keyword.replace(' ', '+')}" + else: + # Regular section URL, add search parameter + search_url = f"{section_url}/search?q={keyword.replace(' ', '+')}" + + # Process search result pages + for page in range(1, max_pages + 1): + if threads_processed >= max_threads: + break + + page_url = f"{search_url}&page={page}" if page > 1 else search_url + print(f"[🔍] Processing page {page}: {page_url}") + + try: + # Add random delay to avoid rate limiting + time.sleep(random.uniform(1, 3)) + + # Get search results page with enhanced headers + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Cache-Control': 'max-age=0' + } + + response = requests.get(page_url, headers=headers, timeout=10) + + if response.status_code != 200: + print(f"[❌] Failed to get search results page: {response.status_code}") + break + + if verbose: + print(f"[🔍] Response received: {len(response.text)} bytes") + + # Check for Cloudflare protection + if "Cloudflare" in response.text and "challenge" in response.text: + print(f"[⚠️] Cloudflare protection detected. Cannot access forum content directly.") + cloudflare_detected = True + break + + # Parse search results + soup = BeautifulSoup(response.text, 'html.parser') + thread_elements = soup.select('.structItem--thread') + + if not thread_elements: + print(f"[⚠️] No threads found on page {page} for keyword '{keyword}' in section '{section}'") + + if verbose: + # Print a snippet of the response to help debug + print(f"[🔍] Response snippet: {response.text[:500]}...") + + # Check if we're getting a search results page at all + search_title = soup.select_one('title') + if search_title: + print(f"[🔍] Page title: {search_title.get_text()}") + + # Check if there's a message about no results + no_results = soup.select_one('.block-row--message') + if no_results: + print(f"[🔍] Message: {no_results.get_text()}") + break + + # Process each thread + for thread_elem in thread_elements: + if threads_processed >= max_threads: + break + + try: + # Extract thread data + title_elem = thread_elem.select_one('.structItem-title') + if not title_elem: + continue + + title = clean_text(title_elem.get_text()) + thread_url = LOWYAT_BASE_URL + title_elem.find('a')['href'] + + # Extract author + author_elem = thread_elem.select_one('.structItem-minor') + author = clean_text(author_elem.get_text()) if author_elem else "Unknown" + + # Extract date + date_elem = thread_elem.select_one('.structItem-startDate time') + date_str = date_elem.get('datetime') if date_elem else "Unknown" + date = extract_date(date_str) + + # Extract preview text if available + preview_elem = thread_elem.select_one('.structItem-excerpt') + preview = clean_text(preview_elem.get_text()) if preview_elem else "" + + # Get thread content + thread_data = get_thread_content(thread_url) + + # Combine data + thread_info = { + "platform": "lowyat_forum", + "section": section, + "title": title, + "author": author, + "date": date, + "url": thread_url, + "preview": preview, + "content": thread_data.get("content", ""), + "replies": thread_data.get("replies", []) + } + + all_threads.append(thread_info) + threads_processed += 1 + print(f"[✓] Processed thread: {title} ({threads_processed}/{max_threads})") + + except Exception as e: + print(f"[❌] Error processing thread: {str(e)}") + + # Check if there are more pages + next_page = soup.select_one('.pageNav-jump--next') + if not next_page: + print(f"[⚠️] No more pages for keyword '{keyword}' in section '{section}'") + break + + except Exception as e: + print(f"[❌] Error processing page {page}: {str(e)}") + break + + # If no threads found and Cloudflare detected, use mock data if enabled + if not all_threads and cloudflare_detected and use_mock_data: + print(f"[ℹ️] Using mock data for Lowyat Forum due to Cloudflare protection") + all_threads = generate_mock_lowyat_data(keywords, sections, max_threads) + + # Save results to cache + if use_cache: + try: + cache_data = { + "threads": all_threads, + "timestamp": datetime.now().isoformat(), + "keywords": keywords, + "sections": sections + } + + with open(cache_file, 'w') as f: + json.dump(cache_data, f) + + print(f"[💾] Saved Lowyat Forum results to cache: {cache_file}") + except Exception as e: + print(f"[⚠️] Error saving Lowyat Forum results to cache: {str(e)}") + + return all_threads + + +def generate_mock_lowyat_data(keywords, sections, max_threads): + """ + Generate mock data for Lowyat Forum when real data cannot be retrieved + + Args: + keywords (list): List of keywords used for the search + sections (list): List of forum sections that were searched + max_threads (int): Maximum number of threads to generate + + Returns: + list: List of mock thread data dictionaries + """ + print(f"[💻] Generating mock data for keywords: {', '.join(keywords)}") + + # Create a list to store mock threads + mock_threads = [] + + # Define some common Malaysian usernames + usernames = [ + "MalaysianGuy", "KLite", "JohorianPride", "PenangFoodie", "SarawakExplorer", + "MalaccaHistory", "SabahAdventure", "IPohBoy", "KuchingCat", "TerengganuDiver", + "PerakMan", "KedahPadi", "NegeriS9", "PahangForest", "MelakaCendol" + ] + + # Define some common topics based on keywords + topics_by_keyword = { + "cukai": [ + "Cukai baharu akan diperkenalkan tahun depan?", + "Pendapat tentang cukai keuntungan modal", + "Cara menjimatkan cukai pendapatan", + "Cukai jualan dan perkhidmatan (SST) vs GST", + "Adakah cukai kereta import akan dikurangkan?" + ], + "minyak sawit": [ + "Harga minyak sawit dijangka naik bulan depan", + "EU ban minyak sawit: Kesan kepada Malaysia", + "Industri minyak sawit dan isu kelestarian", + "Minyak sawit vs minyak zaitun: Mana lebih sihat?", + "Eksport minyak sawit Malaysia meningkat 15%" + ], + "kerajaan": [ + "Kerajaan akan umum inisiatif baharu untuk sektor perumahan", + "Polisi kerajaan untuk industri teknologi", + "Kerajaan perkenal subsidi baharu untuk petani", + "Pandangan tentang prestasi kerajaan semasa", + "Kerajaan lancar program bantuan PKS" + ], + "ekonomi": [ + "Ekonomi Malaysia dijangka pulih pada Q3", + "Kesan inflasi kepada ekonomi tempatan", + "Ringgit vs USD: Analisis semasa", + "Sektor pelancongan menyumbang kepada pemulihan ekonomi", + "Bagaimana keadaan ekonomi mempengaruhi pasaran hartanah?" + ] + } + + # Default topics if no matching keywords + default_topics = [ + "Pandangan tentang isu semasa di Malaysia", + "Perbincangan tentang kenaikan harga barang", + "Cadangan tempat makan sedap di KL", + "Perkongsian pengalaman kerja dari rumah", + "Tips melabur dalam pasaran saham Malaysia" + ] + + # Generate threads for each section + threads_per_section = max(1, max_threads // len(sections)) + + for section in sections: + # Find relevant topics based on keywords + relevant_topics = [] + for keyword in keywords: + keyword_lower = keyword.lower() + # Check if we have predefined topics for this keyword + for k, topics in topics_by_keyword.items(): + if k in keyword_lower or keyword_lower in k: + relevant_topics.extend(topics) + + # If no relevant topics found, use default topics + if not relevant_topics: + relevant_topics = default_topics + + # Generate threads for this section + for i in range(threads_per_section): + if len(mock_threads) >= max_threads: + break + + # Select a topic + topic = random.choice(relevant_topics) + + # Generate a date within the last month + days_ago = random.randint(1, 30) + thread_date = (datetime.now() - timedelta(days=days_ago)).isoformat() + + # Generate content + content = f"Ini adalah perbincangan tentang {topic}. " + content += f"Saya ingin berkongsi pendapat dan mendapatkan maklum balas daripada ahli forum. " + content += f"Apakah pandangan anda tentang perkara ini?" + + # Generate replies + num_replies = random.randint(1, 5) + replies = [] + + for j in range(num_replies): + reply_days_ago = random.randint(0, days_ago) + reply_date = (datetime.now() - timedelta(days=reply_days_ago)).isoformat() + + reply_username = random.choice(usernames) + reply_content = f"Saya bersetuju dengan pendapat anda tentang {topic}. " + reply_content += f"Ini adalah pandangan saya..." + + replies.append({ + "author": reply_username, + "date": reply_date, + "content": reply_content + }) + + # Create thread info + thread_info = { + "platform": "lowyat_forum", + "section": section, + "title": topic, + "author": random.choice(usernames), + "date": thread_date, + "url": f"https://forum.lowyat.net/topic/{random.randint(100000, 999999)}", + "preview": content[:100] + "...", + "content": content, + "replies": replies + } + + mock_threads.append(thread_info) + print(f"[💻] Generated mock thread: {topic} in {section}") + + return mock_threads + +def get_thread_content(thread_url, max_posts=10): + """ + Get content from a Lowyat Forum thread + + Args: + thread_url (str): URL of the thread + max_posts (int): Maximum number of posts to extract + + Returns: + dict: Thread content and replies + """ + try: + # Add random delay to avoid rate limiting + time.sleep(random.uniform(1, 3)) + + # Get thread page + response = requests.get(thread_url, headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + }) + + if response.status_code != 200: + print(f"[❌] Failed to get thread page: {response.status_code}") + return {"content": "", "replies": []} + + # Parse thread page + soup = BeautifulSoup(response.text, 'html.parser') + + # Get main post content + main_post = soup.select_one('.message--post') + content = "" + if main_post: + content_elem = main_post.select_one('.message-body .bbWrapper') + content = clean_text(content_elem.get_text()) if content_elem else "" + + # Get replies + replies = [] + reply_elements = soup.select('.message--post')[1:max_posts+1] # Skip the first post (main content) + + for reply_elem in reply_elements: + try: + # Extract reply author + author_elem = reply_elem.select_one('.message-name') + author = clean_text(author_elem.get_text()) if author_elem else "Unknown" + + # Extract reply date + date_elem = reply_elem.select_one('.message-attribution-main time') + date_str = date_elem.get('datetime') if date_elem else "Unknown" + date = extract_date(date_str) + + # Extract reply content + content_elem = reply_elem.select_one('.message-body .bbWrapper') + reply_content = clean_text(content_elem.get_text()) if content_elem else "" + + replies.append({ + "author": author, + "date": date, + "content": reply_content + }) + except Exception as e: + print(f"[❌] Error processing reply: {str(e)}") + + return { + "content": content, + "replies": replies + } + + except Exception as e: + print(f"[❌] Error getting thread content: {str(e)}") + return {"content": "", "replies": []} + +def convert_to_dataframe(threads): + """ + Convert Lowyat Forum thread data to a DataFrame compatible with the claim analysis system + + Args: + threads (list): List of thread data dictionaries + + Returns: + pandas.DataFrame: DataFrame with standardized columns + """ + records = [] + + for thread in threads: + # Add the main thread as a record + main_record = { + "platform": "LowyatForum", # Changed to standardized label + "date": thread.get("date", ""), + "username": thread.get("author", ""), + "post_text": thread.get("title", "") + " " + thread.get("content", ""), + "post_url": thread.get("url", ""), + "likes": 0, # Lowyat doesn't expose like counts in the HTML + "shares": 0, # No share counts + "comments_count": len(thread.get("replies", [])), + "comment_text": "", + "combined_text": thread.get("title", "") + " " + thread.get("content", "") + } + records.append(main_record) + + # Add each reply as a separate record + for reply in thread.get("replies", []): + reply_record = { + "platform": "LowyatForum_Comment", # Changed to standardized label + "date": reply.get("date", ""), + "username": reply.get("author", ""), + "post_text": "", + "post_url": thread.get("url", ""), + "likes": 0, + "shares": 0, + "comments_count": 0, + "comment_text": reply.get("content", ""), + "combined_text": reply.get("content", "") + } + records.append(reply_record) + + # Create DataFrame + if records: + df = pd.DataFrame(records) + return df + else: + # Return empty DataFrame with correct columns + return pd.DataFrame(columns=[ + "platform", "date", "username", "post_text", "post_url", + "likes", "shares", "comments_count", "comment_text", "combined_text" + ]) + +def run(keywords, sections=None, max_threads=20, output_path=None, full_claim=None, verbose=True, use_mock_data=True): + """ + Run the Lowyat Forum crawler and save results + + Args: + keywords (list): List of keywords to search for + sections (list): List of forum sections to search in + max_threads (int): Maximum number of threads to process + output_path (str): Path to save results CSV + full_claim (str): The full claim text for more targeted searching + verbose (bool): Whether to print verbose output + use_mock_data (bool): Whether to use mock data if real data cannot be retrieved + + Returns: + pandas.DataFrame: DataFrame with crawled data + """ + print(f"[🔍] Starting Lowyat Forum crawler for keywords: {', '.join(keywords)}") + + # Check if this is a crime-related claim about Kelantan + crime_related = any(kw in ["polis", "jenayah", "kes", "rogol", "sumbang mahram"] for kw in keywords) + kelantan_related = any("kelantan" in kw.lower() for kw in keywords) + + # Use the full claim directly if available for crime-related claims in Kelantan + if full_claim and crime_related and kelantan_related: + print(f"[🔍] Using full claim for Lowyat Forum search: {full_claim}") + + # Use the full claim as a single search term + keywords = [full_claim] + + # Also add these specialized keywords for better coverage + specialized_keywords = [ + "polis kelantan", + "kes rogol kelantan", + "sumbang mahram", + "jenayah seksual" + ] + + # Add specialized keywords to the search + keywords.extend(specialized_keywords) + print(f"[🔍] Using keywords: {', '.join(keywords)}") + # Use more targeted keywords for crime-related claims in Kelantan (if no full claim) + elif crime_related and kelantan_related: + print("[🔍] Detected crime-related claim about Kelantan, using specialized keywords") + keywords = [ + "polis kelantan", + "kes rogol kelantan", + "sumbang mahram", + "jenayah seksual" + ] + # Add context-specific keywords for other types of claims + elif full_claim: + # Check for economic/financial claims + if any(term in full_claim.lower() for term in ["ekonomi", "kewangan", "cukai", "subsidi", "harga"]): + print("[🔍] Detected economic/financial claim, adding relevant keywords") + econ_keywords = ["ekonomi malaysia", "kewangan", "cukai", "subsidi", "harga"] + keywords.extend([k for k in econ_keywords if k not in keywords]) + + # Check for political claims + elif any(term in full_claim.lower() for term in ["kerajaan", "politik", "perdana menteri", "kabinet", "parlimen"]): + print("[🔍] Detected political claim, adding relevant keywords") + pol_keywords = ["kerajaan", "politik malaysia", "dasar", "kabinet"] + keywords.extend([k for k in pol_keywords if k not in keywords]) + + # Set default sections if not provided + if sections is None: + sections = ["Kopitiam", "SeriousKopitiam", "Finance"] + + # Validate sections against available forum sections + valid_sections = [section for section in sections if section in FORUM_SECTIONS] + if not valid_sections: + print("[⚠️] No valid forum sections provided. Using default sections.") + valid_sections = ["Kopitiam", "SeriousKopitiam", "Finance"] + + # If sections were invalid, inform the user + if len(valid_sections) != len(sections): + print(f"[⚠️] Some sections were invalid. Using: {', '.join(valid_sections)}") + + # For crime-related topics, prioritize SeriousKopitiam + if crime_related and "SeriousKopitiam" in valid_sections: + # Move SeriousKopitiam to the front of the list + valid_sections.remove("SeriousKopitiam") + valid_sections.insert(0, "SeriousKopitiam") + + # For economic topics, prioritize Finance + elif any(term in "".join(keywords).lower() for term in ["ekonomi", "kewangan", "cukai", "subsidi", "harga"]) and "Finance" in valid_sections: + valid_sections.remove("Finance") + valid_sections.insert(0, "Finance") + + # For political topics, prioritize Politics + elif any(term in "".join(keywords).lower() for term in ["kerajaan", "politik", "perdana menteri", "kabinet", "parlimen"]) and "Politics" in valid_sections: + valid_sections.remove("Politics") + valid_sections.insert(0, "Politics") + + # Search forum with enhanced options + threads = search_lowyat_forum( + keywords, + sections=valid_sections, + max_threads=max_threads, + verbose=verbose, + use_mock_data=use_mock_data + ) + print(f"[✓] Found {len(threads)} threads on Lowyat Forum") + + # Convert to DataFrame + df = convert_to_dataframe(threads) + print(f"[✓] Converted to {len(df)} records") + + # Save to CSV if output path provided + if output_path and len(df) > 0: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + df.to_csv(output_path, index=False) + print(f"[💾] Saved Lowyat Forum data to {output_path}") + elif output_path: + # Create an empty CSV file with the correct columns + empty_df = pd.DataFrame(columns=[ + "platform", "date", "username", "post_text", "post_url", + "likes", "shares", "comments_count", "comment_text", "combined_text" + ]) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + empty_df.to_csv(output_path, index=False) + print(f"[💾] Saved empty Lowyat Forum data file to {output_path}") + + return df + +# Test the crawler if run directly +if __name__ == "__main__": + test_keywords = ["cukai minyak sawit", "palm oil tax"] + test_sections = ["Kopitiam", "Finance"] + + df = run_lowyat_crawler(test_keywords, sections=test_sections, max_threads=10) + print(df.head()) diff --git a/ai_api/library/priority_indexer.py b/ai_api/library/priority_indexer.py new file mode 100644 index 0000000000000000000000000000000000000000..89de4ad63b4125c937aa34c98f290ff86e936d53 --- /dev/null +++ b/ai_api/library/priority_indexer.py @@ -0,0 +1,360 @@ +# priority_indexer.py +import pandas as pd +import json +import os +import re +from datetime import datetime + +def load_agency_keywords(filepath=None): + """ + Load keywords for agency detection or use default keywords if file not found + """ + # Define default agency keywords if file not provided or not found + default_keywords = { + # Government-related keywords + "government": [ + "kerajaan", "menteri", "perdana menteri", "kementerian", "jabatan", + "agensi", "dasar", "parlimen", "dewan rakyat", "dewan negara", + "dun", "pejabat", "keselamatan negara", "atm", "polis", + "kdn", "hasil", "sop", "ancaman", "pentadbiran", "kabinet", + "politik", "ahli parlimen", "wakil rakyat", "adun", "pemimpin", + "ketua menteri", "menteri besar", "exco", "majlis", "pihak berkuasa", + "pbt", "majlis perbandaran", "majlis bandaraya", "dewan bandaraya" + ], + + # Economic keywords + "economic": [ + "ekonomi", "kewangan", "bank", "cukai", "subsidi", "harga", "kos", + "perbelanjaan", "pendapatan", "gaji", "dividen", "saham", "pasaran", + "inflasi", "deflasi", "krisis", "kemelesetan", "pertumbuhan", "gdp", + "kdnk", "pelaburan", "pelabur", "perniagaan", "syarikat", "industri", + "sektor", "perdagangan", "import", "eksport", "mata wang", "ringgit", + "dolar", "hutang", "pinjaman", "faedah", "untung", "rugi", "bayaran", + "fi", "yuran", "perbelanjaan", "pendapatan", "bonus", "elaun", + "insentif", "bantuan", "sumbangan", "derma", "zakat", "duti", + "levi", "caj", "jualan", "belian", "pembelian", "perolehan", + "tender", "kontrak", "projek", "pembangunan", "infrastruktur", + "pembinaan", "hartanah", "rumah", "kediaman", "komersial", + "tanah", "saiz", "keluasan", "murah", "mahal", "berpatutan", + "mampu", "tidak mampu", "bekalan", "stok", "inventori", + "simpanan", "rizab", "aset", "liabiliti", "kredit", "debit", + "ansuran", "keuntungan", "kerugian", "defisit", "surplus", + "lebihan", "kekurangan", "kenaikan", "penurunan", "peningkatan", + "pengurangan", "pemulihan", "pembaikan" + ], + + # Law-related keywords + "law": [ + "undang-undang", "perundangan", "akta", "enakmen", "ordinan", + "peraturan", "perlembagaan", "mahkamah", "hakim", "peguam", + "pendakwa", "pendakwaan", "pertuduhan", "dakwaan", "saman", + "waran", "tangkap", "tahan", "reman", "jamin", "ikat jamin", + "denda", "hukuman", "penjara", "polis", "balai", "laporan", + "aduan", "siasatan", "siasat", "jenayah", "sivil", "kes", + "fail", "bicara", "perbicaraan", "prosiding", "rayuan", + "petisyen", "pindaan", "bon", "jaminan", "saksi", "keterangan", + "bukti", "forensik", "peguambela", "peguamcara", "pendakwa raya", + "majistret", "ketua hakim", "ketua hakim negara", "hakim besar", + "mahkamah tinggi", "mahkamah rayuan", "mahkamah persekutuan", + "mahkamah rendah", "mahkamah majistret", "mahkamah sesyen", + "mahkamah syariah", "pdrm", "ibu pejabat polis", "ketua polis", + "pegawai polis", "anggota polis", "konstabel", "koperal", + "sarjan", "inspektor", "superintendan", "komisioner", "sprm", + "suruhanjaya pencegahan rasuah", "rasuah", "korupsi", + "salah guna kuasa", "penyelewengan", "pecah amanah", + "pengubahan wang haram" + ], + + # Danger-related keywords + "danger": [ + "bahaya", "merbahaya", "risiko", "ancaman", "bencana", "malapetaka", + "tragedi", "musibah", "kemalangan", "nahas", "kecelakaan", "kecederaan", + "kematian", "korban", "mangsa", "kemusnahan", "kerosakan", "kerugian", + "kehilangan", "kecurian", "rompakan", "samun", "ragut", "pecah", + "pecah rumah", "pecah masuk", "curi", "culik", "bunuh", "bunuh diri", + "mati", "cedera", "parah", "kritikal", "koma", "luka", "patah", + "retak", "lebam", "bengkak", "darah", "pendarahan", "kecemasan", + "ambulans", "hospital", "klinik", "doktor", "ubat", "dadah", + "narkotik", "ganja", "heroin", "kokain", "syabu", "pil kuda", + "ekstasi", "ketamin", "morfin", "ketagihan", "penagih", "pengedar", + "sindiket", "kartel", "mafia", "gangster", "kongsi gelap", "geng", + "kumpulan jenayah", "penjenayah", "penjahat", "pesalah", "banduan", + "tahanan", "suspek", "tertuduh", "terdakwa", "senjata", "pistol", + "revolver", "senapang", "rifle", "shotgun", "bom", "granat", + "peluru", "kelongsong", "senjata api", "senjata tajam", "pisau", + "parang", "kapak", "keris", "pedang", "racun", "toksin", "kimia", + "biologi", "nuklear", "radiasi", "sinaran", "letupan", "ledakan", + "kebakaran", "api", "nyalaan", "bara", "asap", "hangus", "terbakar", + "banjir", "bah", "limpahan", "hujan", "ribut", "taufan", "siklon", + "hurikan", "tornado", "puting beliung", "angin kencang", "kilat", + "petir", "guruh", "guntur", "halilintar", "tanah runtuh", "gelinciran tanah", + "runtuhan", "runtuh", "jatuh", "roboh", "rebah", "tumbang", "gempa", + "gempa bumi", "tsunami", "ombak besar", "gelombang tinggi", "kemarau", + "kekeringan", "perang", "pertempuran", "pergaduhan", "perkelahian", + "rusuhan", "kekacauan", "huru-hara", "keganasan", "kekerasan", + "keselamatan", "keselamatan negara", "keselamatan awam", "kanser", + "barah", "tumor", "penyakit", "wabak", "epidemik", "pandemik", + "jangkitan", "virus", "bakteria", "nyawa", "terancam", "maut" + ] + } + + # Try to load from file if provided + if filepath and os.path.exists(filepath): + try: + df = pd.read_csv(filepath) + if 'keyword' in df.columns and 'category' in df.columns: + # Group keywords by category + keywords = {} + for category in df['category'].unique(): + keywords[category] = df[df['category'] == category]['keyword'].tolist() + return keywords + else: + print(f"[⚠️] Warning: Required columns not found in {filepath}. Using default keywords.") + return default_keywords + except Exception as e: + print(f"[⚠️] Error loading agency keywords from {filepath}: {e}") + return default_keywords + else: + if filepath: + print(f"[ℹ️] Agency keywords file not found. Using default keywords.") + return default_keywords + +def analyze_text_content(df, keywords_dict): + """ + Analyze text content in the dataframe to find keywords + Returns a dictionary of found keywords by category + """ + found_keywords = {category: [] for category in keywords_dict.keys()} + + # Combine all text columns + text_columns = ['post_text', 'comment_text', 'title', 'snippet', 'combined_text'] + all_text = "" + + for col in text_columns: + if col in df.columns: + all_text += " " + " ".join(df[col].fillna("").astype(str)) + + all_text = all_text.lower() + + # Search for keywords in the combined text + for category, keywords in keywords_dict.items(): + for keyword in keywords: + if keyword.lower() in all_text: + found_keywords[category].append(keyword) + + # Remove duplicates and limit to top 5 per category + for category in found_keywords: + found_keywords[category] = list(set(found_keywords[category]))[:5] + + return found_keywords + +def calculate_priority_score(flags): + """Calculate priority score based on flags""" + # Base weights for different flags + weights = { + "fact_check_value": 1.0, + "cause_confusion": 1.5, + "cause_chaos": 1.8, + "affects_government": 1.0, + "economic_impact": 0.8, + "law_related": 0.8, + "public_interest": 1.2, + "lives_in_danger": 1.5, + "viral": 1.0, + "urgent": 2.0 + } + + # Calculate weighted score + score = 0 + for flag, value in flags.items(): + if flag in weights and value == 1: + score += weights[flag] + + # Normalize to 0-10 scale + max_possible_score = sum(weights.values()) + normalized_score = (score / max_possible_score) * 10 + + # Cap at 10 + return min(normalized_score, 10.0) + +def get_priority_level(score): + """Get priority level based on score""" + if score >= 8.0: + return "TINGGI" + elif score >= 5.0: + return "SEDERHANA" + else: + return "RENDAH" + +def run(sentiment_csv, agencies_csv=None, output_path=None, claim=None, claim_id=None, keywords=None): + """ + Run priority indexing on sentiment data + + Args: + sentiment_csv (str): Path to sentiment CSV file + agencies_csv (str, optional): Path to agencies CSV file + output_path (str, optional): Path to output JSON file + claim (str, optional): The claim text + claim_id (str, optional): Unique identifier for the claim + keywords (list, optional): List of keywords + + Returns: + dict: Priority report data + """ + print(f"[🔍] Loading sentiment data from: {sentiment_csv}") + + try: + df = pd.read_csv(sentiment_csv) + except Exception as e: + print(f"[❌] Error reading sentiment data: {e}") + return None + + # Load agency keywords + agency_keywords = load_agency_keywords(agencies_csv) + + # Initialize flags + flags = { + "fact_check_value": 0, + "cause_confusion": 0, + "cause_chaos": 0, + "affects_government": 0, + "economic_impact": 0, + "law_related": 0, + "public_interest": 0, + "lives_in_danger": 0, + "viral": 0, + "urgent": 0 + } + + # Calculate sentiment counts + sentiment_counts = df['sentiment'].value_counts().to_dict() + + # Convert numeric sentiments to text + sentiment_map = {0: "neutral", 1: "positive", 2: "negative"} + text_counts = {} + + for k, v in sentiment_counts.items(): + if k in sentiment_map: + text_counts[sentiment_map[k]] = v + else: + text_counts[k] = v + + # Get total records + total_records = len(df) + + # Calculate engagement metrics + total_likes = df['likes'].sum() if 'likes' in df.columns else 0 + total_shares = df['shares'].sum() if 'shares' in df.columns else 0 + total_comments = df['comments'].sum() if 'comments' in df.columns else 0 + total_views = df['views'].sum() if 'views' in df.columns else 0 + + total_engagement = total_likes + total_shares + total_comments + total_views + + # Check fact_check_value flag (based on engagement) + # Rule: High engagement indicates need for fact checking + if total_engagement > 10000: + flags["fact_check_value"] = 1 + print(f"[📊] Flag: fact_check_value triggered (Total engagement: {total_engagement})") + + # Check sentiment-based flags + pos = text_counts.get("positive", 0) + neg = text_counts.get("negative", 0) + neu = text_counts.get("neutral", 0) + + total_sentiment = pos + neg + neu + if total_sentiment > 0: + pos_ratio = pos / total_sentiment + neg_ratio = neg / total_sentiment + neu_ratio = neu / total_sentiment + + # Rule: cause_confusion if positive = negative OR neutral is high + if (abs(pos_ratio - neg_ratio) < 0.2 and pos_ratio > 0.2 and neg_ratio > 0.2) or (neu_ratio > 0.7): + flags["cause_confusion"] = 1 + print(f"[📊] Flag: cause_confusion triggered (Pos: {pos_ratio:.2f}, Neg: {neg_ratio:.2f}, Neu: {neu_ratio:.2f})") + + # Rule: cause_chaos if negative sentiment is high + if neg_ratio > 0.4: + flags["cause_chaos"] = 1 + print(f"[📊] Flag: cause_chaos triggered (Negative: {neg_ratio:.2f})") + + # Analyze text content for keywords + found_keywords = analyze_text_content(df, agency_keywords) + + # Check government-related flag + # Rule: Contains government-related keywords + if found_keywords["government"]: + flags["affects_government"] = 1 + print(f"[📊] Flag: affects_government triggered (Gov terms: {', '.join(found_keywords['government'])})") + + # Check economic impact flag + # Rule: Contains economic-related keywords + if found_keywords["economic"]: + flags["economic_impact"] = 1 + print(f"[📊] Flag: economic_impact triggered (Economic terms: {', '.join(found_keywords['economic'])})") + + # Check law-related flag + # Rule: Contains law-related keywords + if found_keywords["law"]: + flags["law_related"] = 1 + print(f"[📊] Flag: law_related triggered (Law terms: {', '.join(found_keywords['law'])})") + + # Check public interest flag + # Rule: High comments and shares indicate public interest + if (total_comments + total_shares) > 1000: + flags["public_interest"] = 1 + print(f"[📊] Flag: public_interest triggered (Comments + Shares: {total_comments + total_shares})") + + # Check danger-related flag + # Rule: Contains danger-related keywords + if found_keywords["danger"]: + flags["lives_in_danger"] = 1 + print(f"[📊] Flag: lives_in_danger triggered (Danger terms: {', '.join(found_keywords['danger'])})") + + # Check viral flag + # Rule: High shares indicate virality + if total_shares > 1000: + flags["viral"] = 1 + print(f"[📊] Flag: viral triggered (Total shares: {total_shares})") + + # Check urgent flag + # Rule: If 5 or more flags are triggered, it's urgent + flags_triggered = sum(flags.values()) + if flags_triggered >= 5: + flags["urgent"] = 1 + print(f"[📊] Flag: urgent triggered ({flags_triggered} flags triggered)") + + # Calculate priority score + priority_score = calculate_priority_score(flags) + priority_level = get_priority_level(priority_score) + + # Prepare report data + report_data = { + "priority_flags": flags, + "priority_score": priority_score, + "priority_level": priority_level, + "sentiment_counts": text_counts, + "total_records": total_records, + "engagement": { + "likes": int(total_likes), + "shares": int(total_shares), + "comments": int(total_comments), + "views": int(total_views), + "total": int(total_engagement) + }, + "found_keywords": found_keywords, + "claim": claim, + "keywords": keywords, + "timestamp": datetime.now().isoformat() + } + + # Ensure output directory exists + if not output_path: + output_path = os.path.join("reports", os.path.basename(sentiment_csv).replace("_sentiment.csv", "_priority.json")) + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, 'w') as f: + json.dump(report_data, f, indent=4) + + print(f"[📊] Priority index saved to {output_path}") + print(f"[📊] Priority score: {priority_score:.2f}/10 ({priority_level})") + return report_data diff --git a/ai_api/library/sentiment_analyzer.py b/ai_api/library/sentiment_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..38a48ab4b71067f4e3900e6cd5e60542409def10 --- /dev/null +++ b/ai_api/library/sentiment_analyzer.py @@ -0,0 +1,91 @@ +# sentiment_analyzer.py +# Simple sentiment analyzer that doesn't require PyTorch + +import pandas as pd +import re +import random +import os + +def simple_sentiment_analysis(text): + """ + A very simple rule-based sentiment analyzer for demonstration purposes. + Returns a sentiment label (neutral, positive, negative) and confidence score. + """ + if not text or len(text.strip()) < 15: + return "neutral", 0.5 + + # Convert to lowercase + text = text.lower() + + # Define positive and negative word lists (Malay and English) + positive_words = [ + "baik", "bagus", "hebat", "cantik", "indah", "suka", "gembira", "senang", + "setuju", "betul", "benar", "berkesan", "berjaya", "cemerlang", "positif", + "good", "great", "excellent", "amazing", "wonderful", "happy", "like", "love", + "agree", "correct", "true", "effective", "successful", "positive" + ] + + negative_words = [ + "buruk", "teruk", "hodoh", "benci", "marah", "sedih", "kecewa", "susah", + "tidak setuju", "salah", "palsu", "gagal", "negatif", "masalah", "bahaya", + "bad", "terrible", "ugly", "hate", "angry", "sad", "disappointed", "difficult", + "disagree", "wrong", "false", "fail", "negative", "problem", "dangerous" + ] + + # Count positive and negative words + positive_count = sum(1 for word in positive_words if re.search(r'\b' + re.escape(word) + r'\b', text)) + negative_count = sum(1 for word in negative_words if re.search(r'\b' + re.escape(word) + r'\b', text)) + + # Determine sentiment + if positive_count > negative_count: + sentiment = "positive" + confidence = 0.5 + min(0.5, (positive_count - negative_count) / 10) + elif negative_count > positive_count: + sentiment = "negative" + confidence = 0.5 + min(0.5, (negative_count - positive_count) / 10) + else: + sentiment = "neutral" + confidence = 0.5 + + return sentiment, round(confidence, 4) + +def run(csv_path, sentiment_output_path=None): + """ + Runs sentiment analysis on combined comment + post text from the input CSV. + Saves the result (with sentiment + confidence columns) to a new CSV. + """ + print(f"[📄] Reading dataset: {csv_path}") + df = pd.read_csv(csv_path) + + # Combine comment and post text into a single field + df['combined_text'] = df['comment_text'].fillna('') + ". " + df['post_text'].fillna('') + df['combined_text'] = df['combined_text'].str.strip() + + sentiments = [] + confidences = [] + + print("[🔍] Running simple sentiment classification...") + for text in df['combined_text']: + sentiment, confidence = simple_sentiment_analysis(text) + sentiments.append(sentiment) + confidences.append(confidence) + + # Add results to DataFrame + df['sentiment'] = sentiments + df['confidence'] = confidences + + # Map sentiments to numeric values for compatibility with the rest of the system + sentiment_map = { + "neutral": 0, + "positive": 1, + "negative": 2 + } + df['sentiment_value'] = df['sentiment'].map(sentiment_map) + + # Determine the output path dynamically if not provided + if not sentiment_output_path: + sentiment_output_path = csv_path.replace(".csv", "_sentiment.csv") + + df.to_csv(sentiment_output_path, index=False) + print(f"[💾] Sentiment analysis completed. Output saved to: {sentiment_output_path}") + diff --git a/ai_api/library/simple_keyword_extraction.py b/ai_api/library/simple_keyword_extraction.py new file mode 100644 index 0000000000000000000000000000000000000000..6dfea0bda82d87d2edd58af1c14c55b73de32c8f --- /dev/null +++ b/ai_api/library/simple_keyword_extraction.py @@ -0,0 +1,205 @@ +# simple_keyword_extraction.py +# Simple keyword extraction for the claim analysis system + +import re +from collections import Counter + +# Define Malay stopwords +MALAY_STOPWORDS = [ + "ada", "adalah", "adanya", "adapun", "agak", "agaknya", "agar", "akan", "akankah", "akhir", + "akhiri", "akhirnya", "aku", "akulah", "amat", "amatlah", "anda", "andalah", "antar", "antara", + "antaranya", "apa", "apaan", "apabila", "apakah", "apalagi", "apatah", "artinya", "asal", "asalkan", + "atas", "atau", "ataukah", "ataupun", "awal", "awalnya", "bagai", "bagaikan", "bagaimana", "bagaimanakah", + "bagaimanapun", "bagi", "bagian", "bahkan", "bahwa", "bahwasanya", "baik", "bakal", "bakalan", "balik", + "banyak", "bapak", "baru", "bawah", "beberapa", "begini", "beginian", "beginikah", "beginilah", "begitu", + "begitukah", "begitulah", "begitupun", "bekerja", "belakang", "belakangan", "belum", "belumlah", "benar", + "benarkah", "benarlah", "berada", "berakhir", "berakhirlah", "berakhirnya", "berapa", "berapakah", "berapalah", + "berapapun", "berarti", "berawal", "berbagai", "berdatangan", "beri", "berikan", "berikut", "berikutnya", + "berjumlah", "berkali-kali", "berkata", "berkehendak", "berkeinginan", "berkenaan", "berlainan", "berlalu", + "berlangsung", "berlebihan", "bermacam", "bermacam-macam", "bermaksud", "bermula", "bersama", "bersama-sama", + "bersiap", "bersiap-siap", "bertanya", "bertanya-tanya", "berturut", "berturut-turut", "bertutur", "berujar", + "berupa", "besar", "betul", "betulkah", "biasa", "biasanya", "bila", "bilakah", "bisa", "bisakah", "boleh", + "bolehkah", "bolehlah", "buat", "bukan", "bukankah", "bukanlah", "bukannya", "bulan", "bung", "cara", "caranya", + "cukup", "cukupkah", "cukuplah", "cuma", "dahulu", "dalam", "dan", "dapat", "dari", "daripada", "datang", + "dekat", "demi", "demikian", "demikianlah", "dengan", "depan", "di", "dia", "diakhiri", "diakhirinya", "dialah", + "diantara", "diantaranya", "diberi", "diberikan", "diberikannya", "dibuat", "dibuatnya", "didapat", "didatangkan", + "digunakan", "diibaratkan", "diibaratkannya", "diingat", "diingatkan", "diinginkan", "dijawab", "dijelaskan", + "dijelaskannya", "dikarenakan", "dikatakan", "dikatakannya", "dikerjakan", "diketahui", "diketahuinya", "dikira", + "dilakukan", "dilalui", "dilihat", "dimaksud", "dimaksudkan", "dimaksudkannya", "dimaksudnya", "diminta", + "dimintai", "dimisalkan", "dimulai", "dimulailah", "dimulainya", "dimungkinkan", "dini", "dipastikan", + "diperbuat", "diperbuatnya", "dipergunakan", "diperkirakan", "diperlihatkan", "diperlukan", "diperlukannya", + "dipersoalkan", "dipertanyakan", "dipunyai", "diri", "dirinya", "disampaikan", "disebut", "disebutkan", + "disebutkannya", "disini", "disinilah", "ditambahkan", "ditandaskan", "ditanya", "ditanyai", "ditanyakan", + "ditegaskan", "ditujukan", "ditunjuk", "ditunjuki", "ditunjukkan", "ditunjukkannya", "ditunjuknya", "dituturkan", + "dituturkannya", "diucapkan", "diucapkannya", "diungkapkan", "dong", "dua", "dulu", "empat", "enggak", "enggaknya", + "entah", "entahlah", "guna", "gunakan", "hal", "hampir", "hanya", "hanyalah", "hari", "harus", "haruslah", + "harusnya", "hendak", "hendaklah", "hendaknya", "hingga", "ia", "ialah", "ibarat", "ibaratkan", "ibaratnya", + "ibu", "ikut", "ingat", "ingat-ingat", "ingin", "inginkah", "inginkan", "ini", "inikah", "inilah", "itu", + "itukah", "itulah", "jadi", "jadilah", "jadinya", "jangan", "jangankan", "janganlah", "jauh", "jawab", + "jawaban", "jawabnya", "jelas", "jelaskan", "jelaslah", "jelasnya", "jika", "jikalau", "juga", "jumlah", + "jumlahnya", "justru", "kala", "kalau", "kalaulah", "kalaupun", "kalian", "kami", "kamilah", "kamu", "kamulah", + "kan", "kapan", "kapankah", "kapanpun", "karena", "karenanya", "kasus", "kata", "katakan", "katakanlah", + "katanya", "ke", "keadaan", "kebetulan", "kecil", "kedua", "keduanya", "keinginan", "kelamaan", "kelihatan", + "kelihatannya", "kelima", "keluar", "kembali", "kemudian", "kemungkinan", "kemungkinannya", "kenapa", "kepada", + "kepadanya", "kesamaan", "keseluruhan", "keseluruhannya", "keterlaluan", "ketika", "khususnya", "kini", "kinilah", + "kira", "kira-kira", "kiranya", "kita", "kitalah", "kok", "kurang", "lagi", "lagian", "lah", "lain", "lainnya", + "lalu", "lama", "lamanya", "lanjut", "lanjutnya", "lebih", "lewat", "lima", "luar", "macam", "maka", "makanya", + "makin", "malah", "malahan", "mampu", "mampukah", "mana", "manakala", "manalagi", "masa", "masalah", "masalahnya", + "masih", "masihkah", "masing", "masing-masing", "mau", "maupun", "melainkan", "melakukan", "melalui", "melihat", + "melihatnya", "memang", "memastikan", "memberi", "memberikan", "membuat", "memerlukan", "memihak", "meminta", + "memintakan", "memisalkan", "memperbuat", "mempergunakan", "memperkirakan", "memperlihatkan", "mempersiapkan", + "mempersoalkan", "mempertanyakan", "mempunyai", "memulai", "memungkinkan", "menaiki", "menambahkan", "menandaskan", + "menanti", "menanti-nanti", "menantikan", "menanya", "menanyai", "menanyakan", "mendapat", "mendapatkan", + "mendatang", "mendatangi", "mendatangkan", "menegaskan", "mengakhiri", "mengapa", "mengatakan", "mengatakannya", + "mengenai", "mengerjakan", "mengetahui", "menggunakan", "menghendaki", "mengibaratkan", "mengibaratkannya", + "mengingat", "mengingatkan", "menginginkan", "mengira", "mengucapkan", "mengucapkannya", "mengungkapkan", + "menjadi", "menjawab", "menjelaskan", "menuju", "menunjuk", "menunjuki", "menunjukkan", "menunjuknya", "menurut", + "menuturkan", "menyampaikan", "menyangkut", "menyatakan", "menyebutkan", "menyeluruh", "menyiapkan", "merasa", + "mereka", "merekalah", "merupakan", "meski", "meskipun", "meyakini", "meyakinkan", "minta", "mirip", "misal", + "misalkan", "misalnya", "mula", "mulai", "mulailah", "mulanya", "mungkin", "mungkinkah", "nah", "naik", "namun", + "nanti", "nantinya", "nyaris", "nyatanya", "oleh", "olehnya", "pada", "padahal", "padanya", "pak", "paling", + "panjang", "pantas", "para", "pasti", "pastilah", "penting", "pentingnya", "per", "percuma", "perlu", "perlukah", + "perlunya", "pernah", "persoalan", "pertama", "pertama-tama", "pertanyaan", "pertanyakan", "pihak", "pihaknya", + "pukul", "pula", "pun", "punya", "rasa", "rasanya", "rata", "rupanya", "saat", "saatnya", "saja", "sajalah", + "saling", "sama", "sama-sama", "sambil", "sampai", "sampai-sampai", "sampaikan", "sana", "sangat", "sangatlah", + "satu", "saya", "sayalah", "se", "sebab", "sebabnya", "sebagai", "sebagaimana", "sebagainya", "sebagian", + "sebaik", "sebaik-baiknya", "sebaiknya", "sebaliknya", "sebanyak", "sebegini", "sebegitu", "sebelum", "sebelumnya", + "sebenarnya", "seberapa", "sebesar", "sebetulnya", "sebisanya", "sebuah", "sebut", "sebutlah", "sebutnya", + "secara", "secukupnya", "sedang", "sedangkan", "sedemikian", "sedikit", "sedikitnya", "seenaknya", "segala", + "segalanya", "segera", "seharusnya", "sehingga", "seingat", "sejak", "sejauh", "sejenak", "sejumlah", "sekadar", + "sekadarnya", "sekali", "sekali-kali", "sekalian", "sekaligus", "sekalipun", "sekarang", "sekarang", "sekecil", + "seketika", "sekiranya", "sekitar", "sekitarnya", "sekurang-kurangnya", "sekurangnya", "sela", "selain", "selaku", + "selalu", "selama", "selama-lamanya", "selamanya", "selanjutnya", "seluruh", "seluruhnya", "semacam", "semakin", + "semampu", "semampunya", "semasa", "semasih", "semata", "semata-mata", "semaunya", "sementara", "semisal", + "semisalnya", "sempat", "semua", "semuanya", "semula", "sendiri", "sendirian", "sendirinya", "seolah", + "seolah-olah", "seorang", "sepanjang", "sepantasnya", "sepantasnyalah", "seperlunya", "seperti", "sepertinya", + "sepihak", "sering", "seringnya", "serta", "serupa", "sesaat", "sesama", "sesampai", "sesegera", "sesekali", + "seseorang", "sesuatu", "sesuatunya", "sesudah", "sesudahnya", "setelah", "setempat", "setengah", "seterusnya", + "setiap", "setiba", "setibanya", "setidak-tidaknya", "setidaknya", "setinggi", "seusai", "sewaktu", "siap", + "siapa", "siapakah", "siapapun", "sini", "sinilah", "soal", "soalnya", "suatu", "sudah", "sudahkah", "sudahlah", + "supaya", "tadi", "tadinya", "tahu", "tahun", "tak", "tambah", "tambahnya", "tampak", "tampaknya", "tandas", + "tandasnya", "tanpa", "tanya", "tanyakan", "tanyanya", "tapi", "tegas", "tegasnya", "telah", "tempat", "tengah", + "tentang", "tentu", "tentulah", "tentunya", "tepat", "terakhir", "terasa", "terbanyak", "terdahulu", "terdapat", + "terdiri", "terhadap", "terhadapnya", "teringat", "teringat-ingat", "terjadi", "terjadilah", "terjadinya", + "terkira", "terlalu", "terlebih", "terlihat", "termasuk", "ternyata", "tersampaikan", "tersebut", "tersebutlah", + "tertentu", "tertuju", "terus", "terutama", "tetap", "tetapi", "tiap", "tiba", "tiba-tiba", "tidak", "tidakkah", + "tidaklah", "tiga", "tinggi", "toh", "tunjuk", "turut", "tutur", "tuturnya", "ucap", "ucapnya", "ujar", "ujarnya", + "umum", "umumnya", "ungkap", "ungkapnya", "untuk", "usah", "usai", "waduh", "wah", "wahai", "waktu", "waktunya", + "walau", "walaupun", "wong", "yaitu", "yakin", "yakni", "yang", "ke", "pada", "ini", "itu", "juga", "dari", "dalam", + "akan", "jika", "maka", "karena", "oleh", "dengan", "atau", "secara", "untuk", "adalah", "sebagai", "bahwa", "hanya", + "namun", "tetapi", "ketika", "setelah", "sebelum", "selama", "sejak", "hingga", "sampai", "tentang", "seperti", + "terhadap", "melalui", "menurut", "berdasarkan", "mengenai", "antara", "di", "si", "sang", "para", "the", "of", "and", + "a", "to", "in", "that", "it", "with", "as", "for", "on", "was", "is", "by", "at", "this", "an", "are", "not", "from", + "but", "have", "had", "has", "be", "been", "were", "which", "or", "we", "their", "his", "her", "they", "its", "he", + "she", "you", "my", "all", "can", "would", "could", "should", "may", "might", "must", "shall", "will", "them", "there", + "these", "those", "some", "any", "no", "nor", "so", "such", "than", "then", "thus", "up", "down", "out", "about", "into", + "over", "under", "again", "further", "then", "once", "here", "there", "when", "where", "why", "how", "what", "who", + "whom", "this", "that", "am", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "having", "do", + "does", "did", "doing", "i", "me", "my", "myself", "we", "our", "ours", "ourselves", "you", "your", "yours", "yourself", + "yourselves", "he", "him", "his", "himself", "she", "her", "hers", "herself", "it", "its", "itself", "they", "them", + "their", "theirs", "themselves", "what", "which", "who", "whom", "this", "that", "these", "those", "am", "is", "are", + "was", "were", "be", "been", "being", "have", "has", "had", "having", "do", "does", "did", "doing", "a", "an", "the", + "and", "but", "if", "or", "because", "as", "until", "while", "of", "at", "by", "for", "with", "about", "against", + "between", "into", "through", "during", "before", "after", "above", "below", "to", "from", "up", "down", "in", "out", + "on", "off", "over", "under", "again", "further", "then", "once", "here", "there", "when", "where", "why", "how", "all", + "any", "both", "each", "few", "more", "most", "other", "some", "such", "no", "nor", "not", "only", "own", "same", "so", + "than", "too", "very", "s", "t", "can", "will", "just", "don", "should", "now" +] + +def extract_keywords(text, top_n=10): + """ + Extract keywords from text using a simple frequency-based approach + + Args: + text (str): Text to extract keywords from + top_n (int): Number of keywords to extract + + Returns: + list: List of extracted keywords + """ + # Convert to lowercase + text = text.lower() + + # Remove punctuation and split into words + words = re.findall(r'\b\w+\b', text) + + # Remove stopwords + words = [word for word in words if word not in MALAY_STOPWORDS and len(word) > 2] + + # Count word frequencies + word_counts = Counter(words) + + # Get top N keywords + keywords = [word for word, count in word_counts.most_common(top_n)] + + # If we have fewer than top_n keywords, return what we have + return keywords + +def optimize_keywords_for_platforms(keywords): + """ + Optimize keywords for different platforms + + Args: + keywords (list): List of keywords + + Returns: + dict: Dictionary with optimized keywords for each platform + """ + return { + "tiktok": keywords[:3], + "web_search": keywords[:5] + } + +def detect_claim_type(text): + """ + Detect the type of claim based on keywords + + Args: + text (str): The claim text + + Returns: + str: The type of claim + """ + text = text.lower() + + # Define keyword sets for different claim types + economic_keywords = ["ekonomi", "cukai", "harga", "kewangan", "bank", "ringgit", "subsidi", "kos", "bayaran", "hutang"] + political_keywords = ["kerajaan", "politik", "perdana menteri", "menteri", "parlimen", "pilihan raya", "parti", "kabinet"] + health_keywords = ["kesihatan", "penyakit", "hospital", "vaksin", "ubat", "doktor", "covid", "virus", "pandemik"] + social_keywords = ["sosial", "masyarakat", "pendidikan", "sekolah", "universiti", "pelajar", "guru", "agama"] + security_keywords = ["keselamatan", "polis", "tentera", "jenayah", "penjenayah", "senjata", "serangan"] + + # Count matches for each category + economic_count = sum(1 for keyword in economic_keywords if keyword in text) + political_count = sum(1 for keyword in political_keywords if keyword in text) + health_count = sum(1 for keyword in health_keywords if keyword in text) + social_count = sum(1 for keyword in social_keywords if keyword in text) + security_count = sum(1 for keyword in security_keywords if keyword in text) + + # Determine the dominant category + counts = { + "Ekonomi": economic_count, + "Politik": political_count, + "Kesihatan": health_count, + "Sosial": social_count, + "Keselamatan": security_count + } + + # Get the category with the highest count + dominant_category = max(counts, key=counts.get) + + # If no matches, return "Umum" + if counts[dominant_category] == 0: + return "Umum" + + return dominant_category + +if __name__ == "__main__": + # Test the function + test_text = "Perkenal Cukai Khas Minyak Sawit Mentah Adalah Cadangan Sebuah Persatuan, Bukannya Kerajaan" + keywords = extract_keywords(test_text) + print(f"Extracted keywords: {keywords}") + + optimized = optimize_keywords_for_platforms(keywords) + print(f"Optimized for TikTok: {optimized['tiktok']}") + print(f"Optimized for web search: {optimized['web_search']}") diff --git a/ai_api/library/websearch.py b/ai_api/library/websearch.py new file mode 100644 index 0000000000000000000000000000000000000000..064a9c8e31b818e0e1483ca6c9569928e4368d34 --- /dev/null +++ b/ai_api/library/websearch.py @@ -0,0 +1,237 @@ +""" +run_web_search.py +Module for running web searches and saving results +""" + +import pandas as pd +from datetime import datetime +import os + +def run(keywords, output_path, num_results=5, use_serpapi=True, use_serper=True, use_duckduckgo=True, full_claim=None): + """ + Run web search for keywords and save results to CSV + + Args: + keywords (list): List of keywords to search for + output_path (str): Path to save results + num_results (int): Number of results per keyword + use_serpapi (bool): Whether to use SerpApi + use_serper (bool): Whether to use Serper.dev + use_duckduckgo (bool): Whether to use DuckDuckGo + full_claim (str): The full claim text to use as a search query + + Returns: + int: Number of results saved + """ + # Import search functions + try: + from web_search import search_serpapi, search_serper, search_duckduckgo, get_google_trends + except ImportError: + print("Error importing web_search module. Make sure it exists and is accessible.") + return 0 + + # Create search queries + all_results = [] + + # Always use the full claim directly if available + if full_claim: + print(f"Using full claim as direct search query: '{full_claim}'") + + # Search using SerpApi with the exact claim + if use_serpapi: + print("Searching with SerpApi (exact claim)...") + serpapi_results = search_serpapi(full_claim, num_results=num_results) + if serpapi_results: + print(f"Found {len(serpapi_results)} results from SerpApi (exact claim)") + all_results.extend(serpapi_results) + else: + print("No results from SerpApi (exact claim)") + + # Search using Serper.dev with the exact claim + if use_serper: + print("Searching with Serper.dev (exact claim)...") + serper_results = search_serper(full_claim, num_results=num_results) + if serper_results: + print(f"Found {len(serper_results)} results from Serper.dev (exact claim)") + all_results.extend(serper_results) + else: + print("No results from Serper.dev (exact claim)") + + # For crime-related claims, also try targeted queries + crime_related = any(term in full_claim.lower() for term in ["polis", "jenayah", "kes", "rogol", "sumbang mahram"]) + kelantan_related = "kelantan" in full_claim.lower() + + if crime_related and kelantan_related: + # Check if this is about sexual crimes or ammunition + ammunition_related = any(term in full_claim.lower() for term in ["kelongsong", "peluru", "senjata", "tan"]) + + if ammunition_related: + targeted_queries = [ + "50 tan kelongsong peluru ditemui", + "kilang haram proses kelongsong peluru", + "penemuan kelongsong peluru di kilang", + "kelongsong peluru musuh negara" + ] + else: + # Default to sexual crime queries + targeted_queries = [ + "statistik jenayah seksual di kelantan", + "kes rogol dan sumbang mahram di kelantan meningkat", + "pdrm kelantan lapor kes rogol" + ] + + for query in targeted_queries: + print(f"Using targeted query: '{query}'") + + # Search using SerpApi + if use_serpapi: + print(f"Searching with SerpApi (targeted query: {query})...") + serpapi_results = search_serpapi(query, num_results=num_results//2) # Use fewer results for each targeted query + if serpapi_results: + print(f"Found {len(serpapi_results)} results from SerpApi (targeted query)") + all_results.extend(serpapi_results) + else: + print(f"No results from SerpApi (targeted query: {query})") + + # Search using Serper.dev + if use_serper: + print(f"Searching with Serper.dev (targeted query: {query})...") + serper_results = search_serper(query, num_results=num_results//2) # Use fewer results for each targeted query + if serper_results: + print(f"Found {len(serper_results)} results from Serper.dev (targeted query)") + all_results.extend(serper_results) + else: + print(f"No results from Serper.dev (targeted query: {query})") + else: + # For other claims, use the original approach with keywords + # 1. Full claim query (if available) + full_claim_query = f'"{full_claim}"' if full_claim else None + + # 2. Keyword-based query + search_terms = [] + for kw in keywords: + # If keyword contains spaces (multi-word phrase), wrap in quotes + if " " in kw: + search_terms.append(f'"{kw}"') + else: + # For single words, don't use quotes to get broader results + search_terms.append(kw) + + keyword_query = " OR ".join(search_terms) + + # Search using full claim first (if available) + if full_claim_query: + print(f"Searching with full claim: {full_claim_query}") + + # Search using SerpApi + if use_serpapi: + print("Searching with SerpApi (full claim)...") + serpapi_results = search_serpapi(full_claim, num_results=num_results) + if serpapi_results: + print(f"Found {len(serpapi_results)} results from SerpApi (full claim)") + all_results.extend(serpapi_results) + else: + print("No results from SerpApi (full claim)") + + # Search using Serper.dev + if use_serper: + print("Searching with Serper.dev (full claim)...") + serper_results = search_serper(full_claim, num_results=num_results) + if serper_results: + print(f"Found {len(serper_results)} results from Serper.dev (full claim)") + all_results.extend(serper_results) + else: + print("No results from Serper.dev (full claim)") + + # Search using keyword query as fallback + if not all_results or len(all_results) < num_results: + print(f"Searching with keyword query: {keyword_query}") + + # Search using SerpApi + if use_serpapi: + print("Searching with SerpApi (keywords)...") + serpapi_results = search_serpapi(keyword_query, num_results=num_results) + if serpapi_results: + print(f"Found {len(serpapi_results)} results from SerpApi (keywords)") + all_results.extend(serpapi_results) + else: + print("No results from SerpApi (keywords)") + + # Search using Serper.dev + if use_serper: + print("Searching with Serper.dev (keywords)...") + serper_results = search_serper(keyword_query, num_results=num_results) + if serper_results: + print(f"Found {len(serper_results)} results from Serper.dev (keywords)") + all_results.extend(serper_results) + else: + print("No results from Serper.dev (keywords)") + + # Add DuckDuckGo results + if use_duckduckgo: + query_to_use = full_claim if full_claim else keyword_query + print(f"Searching with DuckDuckGo using: {query_to_use}") + duckduckgo_results = search_duckduckgo(query_to_use, num_results=num_results) + if duckduckgo_results: + print(f"Found {len(duckduckgo_results)} results from DuckDuckGo") + all_results.extend(duckduckgo_results) + else: + print("No results from DuckDuckGo") + + # Add Google Trends data + trends_data = get_google_trends(keywords) + + # Convert to DataFrame + if all_results: + # Remove duplicates based on URL + unique_results = [] + seen_urls = set() + + for result in all_results: + url = result.get('link', '') + if url and url not in seen_urls: + seen_urls.add(url) + unique_results.append(result) + + print(f"Removed {len(all_results) - len(unique_results)} duplicate results") + + df = pd.DataFrame(unique_results) + + # Add additional columns to match the format expected by the sentiment analyzer + df['platform'] = 'web' + df['username'] = df['source'] + df['post_text'] = df['snippet'] + df['post_url'] = df['link'] + df['likes'] = 0 + df['shares'] = 0 + df['comments_count'] = 0 + df['comment_text'] = '' + df['combined_text'] = df['title'] + ' ' + df['snippet'] + df['date'] = datetime.now().strftime('%Y-%m-%d') + + # Create output directory if it doesn't exist + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Save to CSV + df.to_csv(output_path, index=False) + print(f"Saved {len(df)} web search results to {output_path}") + return len(df) + else: + print("No web search results found") + return 0 + +# Test the module +if __name__ == "__main__": + import sys + + # Get keywords from command line or use default + if len(sys.argv) > 1: + keywords = sys.argv[1:] + full_claim = " ".join(sys.argv[1:]) + else: + keywords = ["polis", "kelantan", "sumbang mahram", "rogol"] + full_claim = "Polis Kelantan bimbang kes sumbang mahram dan rogol di Kelantan" + + # Run web search + output_path = "output/web_search_results.csv" + run_web_search(keywords, output_path, num_results=10, full_claim=full_claim) diff --git a/ai_api/middleware.py b/ai_api/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..a4f8c2e826b49b5008246020aa75d44219578700 --- /dev/null +++ b/ai_api/middleware.py @@ -0,0 +1,40 @@ +# middleware.py +import hashlib +import hmac +from django.http import JsonResponse +from ai_api.models import APIClient + +class HMACAuthMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # if request.path.startswith('/admin/'): + # return self.get_response(request) + if not request.path.startswith('/api/'): + return self.get_response(request) + + client_id = request.headers.get('X-Client-ID') + signature = request.headers.get('X-Signature') + + if not client_id or not signature: + return JsonResponse({'error': 'Missing credentials'}, status=401) + + from ai_api.models import APIClient + try: + client = APIClient.objects.get(client_id=client_id) + except APIClient.DoesNotExist: + return JsonResponse({'error': 'Invalid client ID'}, status=401) + + expected_signature = hmac.new( + client.secret_key.encode(), + request.body, + hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(expected_signature, signature): + return JsonResponse({'error': 'Invalid signature'}, status=401) + + request.api_client = client + return self.get_response(request) + diff --git a/ai_api/migrations/0001_initial.py b/ai_api/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..b41dcfbe3643c595120c3e24d663a38f2466d284 --- /dev/null +++ b/ai_api/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.20 on 2025-05-08 00:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='APIClient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('client_id', models.CharField(editable=False, max_length=32, unique=True)), + ('secret_key', models.CharField(editable=False, max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/ai_api/migrations/__init__.py b/ai_api/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/ai_api/models.py b/ai_api/models.py new file mode 100644 index 0000000000000000000000000000000000000000..6e93b615ef8ae12b4efc597e86b90811d85fe544 --- /dev/null +++ b/ai_api/models.py @@ -0,0 +1,18 @@ +from django.db import models +import secrets + +class APIClient(models.Model): + name = models.CharField(max_length=100, unique=True) + client_id = models.CharField(max_length=32, unique=True, editable=False) + secret_key = models.CharField(max_length=64, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if not self.client_id: + self.client_id = secrets.token_hex(16) + if not self.secret_key: + self.secret_key = secrets.token_hex(32) + super().save(*args, **kwargs) + + def __str__(self): + return self.name diff --git a/ai_api/request_serializer.py b/ai_api/request_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..af0deaaff70c5a99394a2da03e476e39f7e70ad1 --- /dev/null +++ b/ai_api/request_serializer.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +class TranscriptionRequestSerializer(serializers.Serializer): + url = serializers.URLField(required=False, allow_null=True) + media = serializers.FileField(required=False, allow_null=True) + + def validate(self, attrs): + url = attrs.get('url') + media = attrs.get('media') + + if not url and not media: + raise serializers.ValidationError("Either 'url' or 'media' must be provided.") + + return attrs + + def validate_media(self, file): + if file is None: + return file + + allowed_types = ['audio/', 'video/'] + content_type = getattr(file, 'content_type', '') + + if not any(content_type.startswith(t) for t in allowed_types): + raise serializers.ValidationError("Only audio or video files are allowed.") + + return file + +class ClassificationRequestSerializer(serializers.Serializer): + claim = serializers.CharField() + diff --git a/ai_api/templates/base-copy.html b/ai_api/templates/base-copy.html new file mode 100644 index 0000000000000000000000000000000000000000..b649d7a52afbc8b8c152fc24e4a10cd321c7dd0f --- /dev/null +++ b/ai_api/templates/base-copy.html @@ -0,0 +1,35 @@ + + + + + + + {% block title %}My Django Project{% endblock %} + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/ai_api/templates/base.html b/ai_api/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..bb5e6fe2792df8d99e65d917ca3a24ae99ec3e68 --- /dev/null +++ b/ai_api/templates/base.html @@ -0,0 +1,61 @@ + + + + + + {% block title %}BERNAMA Fact Check{% endblock %} + {% load static %} + + + + + + + + + + + + + +
+
+

AI Feature Testing Bed

+

Experiment with cutting-edge AI modules like Face Recognition and Speech Transcription in one place.

+ Explore Features +
+
+ + +
+
+ {% block content %}{% endblock %} +
+
+ + + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/ai_api/templates/classification.html b/ai_api/templates/classification.html new file mode 100644 index 0000000000000000000000000000000000000000..ae50cce9bd86280c14b23aaa0ae8a80affa1bc59 --- /dev/null +++ b/ai_api/templates/classification.html @@ -0,0 +1,142 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Classification

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ + + + + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/ai_api/templates/home-copy.html b/ai_api/templates/home-copy.html new file mode 100644 index 0000000000000000000000000000000000000000..da392ff1476810e9f739da644db32b667deacb56 --- /dev/null +++ b/ai_api/templates/home-copy.html @@ -0,0 +1,38 @@ + +{% extends 'base.html' %} + +{% block title %}Welcome to My Homepage{% endblock %} + +{% block content %} +

BERNAMA Fact Check Test Bed!

+
+
+
+
Claim Classification
+

Input a claim and submit for AI to classify the statement.

+ Test Now +
+
+
+
+
Image Profiling
+

Upload an image for AI to analyze.

+ Test Now +
+
+
+
+
Register New Face
+

Insert a person name for AI to learn face recongnition.

+ Test Now +
+
+
+
+
Transcription
+

Audio/Video to transcription (text)

+ Test Now +
+
+
+{% endblock %} diff --git a/ai_api/templates/home.html b/ai_api/templates/home.html new file mode 100644 index 0000000000000000000000000000000000000000..271c14d7960d6aeb89ddf1f050d8411c2a0e11c7 --- /dev/null +++ b/ai_api/templates/home.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} + +{% block title %}BERNAMA Fact Check{% endblock %} + +{% block content %} + + +
+
+

Core AI Modules

+ +
+
+ +{% endblock %} diff --git a/ai_api/templates/image_profiling.html b/ai_api/templates/image_profiling.html new file mode 100644 index 0000000000000000000000000000000000000000..5565ba7c1934319238f802547e38ffbc12a26cc1 --- /dev/null +++ b/ai_api/templates/image_profiling.html @@ -0,0 +1,122 @@ +{% extends 'base.html' %} +{% block content %} +

Image Processing

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ +{% if proccessed %} +
+ + +
+
+ Uploaded Image +
+ +
+ {% if cropped_faces %} +
+
+

Detected Faces

+ Detected Faces +
+ +
+

Cropped Faces

+
+ {% for face, face_name, distance, fdescription in cropped_faces %} +
+ Cropped Face +
+ {{ face_name }}
{{ fdescription }} +
+
+ {% endfor %} +
+
+
+ {% endif %} +
+ +
+ {% if texts %} +
+ {% for text in texts %} + {{ text }} + {% endfor %} +
+ {% endif %} +
+ +
+
+ {% if metadata %} +
+ + + + + + + + + {% for tag, value in metadata.items %} + + + + + {% endfor %} + +
IPTC FieldValue
{{ tag }}{{ value }}
+
+ {% endif %} + + {% if exifs %} +
+ + + + + + + + + {% for tag, value in exifs.items %} + + + + + {% endfor %} + +
EXIF FieldValue
{{ tag }}{{ value }}
+
+ {% endif %} +
+
+ +
+ {% if description %} +

{{ description }}

+ {% endif %} +
+ +
+ {% if reverse_images %} + {{ reverse_images }} + {% endif %} +
+
+
+ +{% endif %} +{% endblock %} diff --git a/ai_api/templates/register_face.html b/ai_api/templates/register_face.html new file mode 100644 index 0000000000000000000000000000000000000000..df00d7e4d1ce1f65c8435bf25c549c42ef02e86d --- /dev/null +++ b/ai_api/templates/register_face.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} +{% block content %} +

Face Register

+ +
+ {% csrf_token %} +
+
+ + {{ form.person }} +
+ +
+ + {{ form.keywords }} +
+
+ +
+
+ + {{ form.images }} +
+
+ + +
+ +{% if result %} +
+

{{ result }}

+
+{% endif %} +{% endblock %} diff --git a/ai_api/templates/transcription.html b/ai_api/templates/transcription.html new file mode 100644 index 0000000000000000000000000000000000000000..6f2681f56a855640449d939abd2ea7ed178997dd --- /dev/null +++ b/ai_api/templates/transcription.html @@ -0,0 +1,159 @@ +{% extends 'base.html' %} +{% block content %} +

Transcription

+ +
+ {% csrf_token %} + {{ form.as_p }} + + +
+ + +
+
+
+
+ + +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + + + + +{% endblock %} diff --git a/ai_api/tests.py b/ai_api/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/ai_api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/ai_api/urls.py b/ai_api/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..ef9ba62f18bbdd9485df20c85268cbef65442822 --- /dev/null +++ b/ai_api/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + + +urlpatterns = [ + path('', views.home, name='home'), + path('classification/', views.classification, name='classification'), + path('image_profiling/', views.image_profiling, name='image_profiling'), + path('register_face/', views.register_face, name='register_face'), + path('transcription/', views.transcription, name='transcription'), + path('progress//', views.check_progress, name='check_progress'), +] diff --git a/ai_api/views.py b/ai_api/views.py new file mode 100644 index 0000000000000000000000000000000000000000..290e361da56d3767672a9785d108b756f600b163 --- /dev/null +++ b/ai_api/views.py @@ -0,0 +1,799 @@ +from django.shortcuts import render +from django.http import JsonResponse +from .forms import ImageUploadForm, ClassificationForm, RegisterFaceForm,TranscribeForm, YouTubeURLForm +import shutil +from django.conf import settings +import torch +import json +import os +from PIL import Image as PILImage +import io +import tempfile +from django.core.cache import cache +import numpy as numpy_lib +import pickle +from deepface import DeepFace +import cv2 +import base64 +from io import BytesIO +from . import globals +import tempfile +import mimetypes +import subprocess +import logging +import uuid +import yt_dlp +import time +import re +from pydub import AudioSegment +import pandas as pd +import csv + + +# Setup logging for error handling +logger = logging.getLogger(__name__) + +# from ai_api.library.devlab_image import DevLabImage + +# devlab_image = DevLabImage() + + +model = globals.model +tokenizer = globals.tokenizer +devlab_image = globals.devlab_image + +with open(f"{globals.save_path}/label_map.json", "r") as f: + label_map = json.load(f) + +index_to_label = {v: k for k, v in label_map.items()} + + +# Create your views here. +def home(request): + return render(request, 'home.html') + + +def classification(request): + from .library import simple_keyword_extraction, apify_scraper, priority_indexer, websearch, lowyat_crawler, sentiment_analyzer + + if request.method == 'POST': + progress_key = request.POST.get("progress_key", str(uuid.uuid4())) + cache.set(progress_key, {'stage': 'starting', 'percent': 0}) + + text = request.POST.get("claim", "") + if not text: + return JsonResponse({"error": "No text provided"}, status=400) + + claim_id = str(uuid.uuid4())[:8] + + try: + # Step 1: Classification + cache.set(progress_key, {'stage': 'classifying', 'percent': 10}) + inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True) + with torch.no_grad(): + outputs = model(**inputs) + prediction = torch.argmax(outputs.logits, dim=-1).item() + classification_result = index_to_label.get(prediction, "Unknown") + + # Step 2: Keyword Extraction + cache.set(progress_key, {'stage': 'extracting_keywords', 'percent': 20}) + keywords = simple_keyword_extraction.extract_keywords(text) + + # Step 3: Setup paths + output_path = os.path.join(settings.BASE_DIR, 'ai_api', 'library', 'output') + report_path = os.path.join(settings.BASE_DIR, 'ai_api', 'library', 'reports') + raw_data_path = os.path.join(output_path, f'{claim_id}.csv') + + # Step 4: Run TikTok scraper + cache.set(progress_key, {'stage': 'scraping_tiktok', 'percent': 30}) + apify_scraper.run( + keywords, + output_path=raw_data_path, + ) + + # Step 5: Run web search + cache.set(progress_key, {'stage': 'searching_web', 'percent': 50}) + web_search_results = websearch.run( + keywords, + output_path=os.path.join(output_path, f"{claim_id}_web.json"), + full_claim=text + ) + + # Step 6: Run Lowyat forum crawler + cache.set(progress_key, {'stage': 'crawling_forum', 'percent': 60}) + lowyat_path = os.path.join(output_path, f"{claim_id}_lowyat.csv") + lowyat_sections = ["Kopitiam", "SeriousKopitiam"] + lowyat_results = lowyat_crawler.run( + keywords, + sections=lowyat_sections, + output_path=lowyat_path, + full_claim=text + ) + + # Step 7: Combine datasets + cache.set(progress_key, {'stage': 'combining_data', 'percent': 70}) + if os.path.exists(lowyat_path): + lowyat_df = pd.read_csv(lowyat_path) + if os.path.exists(raw_data_path): + main_df = pd.read_csv(raw_data_path) + combined_df = pd.concat([main_df, lowyat_df], ignore_index=True) + combined_df.to_csv(raw_data_path, index=False) + else: + lowyat_df.to_csv(raw_data_path, index=False) + + # Step 8: Run sentiment analysis + cache.set(progress_key, {'stage': 'analyzing_sentiment', 'percent': 80}) + sentiment_csv = os.path.join(output_path, f"{claim_id}_sentiment.csv") + sentiment_data = {} + + if os.path.exists(raw_data_path): + sentiment_analyzer.run(raw_data_path, sentiment_csv) + + if os.path.exists(sentiment_csv): + sentiment_df = pd.read_csv(sentiment_csv) + sentiment_counts = sentiment_df['sentiment'].value_counts().to_dict() + sentiment_map = {0: "neutral", 1: "positive", 2: "negative"} + text_counts = {sentiment_map.get(k, k): v for k, v in sentiment_counts.items()} + sentiment_data = { + 'counts': text_counts, + 'table_html': csv_to_html_table(sentiment_csv) + } + + # Step 9: Run priority indexing + cache.set(progress_key, {'stage': 'indexing_priority', 'percent': 90}) + priority_json = os.path.join(report_path, f"{claim_id}_priority.json") + priority_data = {} + + if os.path.exists(sentiment_csv): + priority_indexer.run( + claim=text, + claim_id=claim_id, + keywords=keywords, + sentiment_csv=sentiment_csv, + output_path=priority_json + ) + + if os.path.exists(priority_json): + with open(priority_json, 'r') as f: + priority_data = json.load(f) + verdict = determine_verdict(priority_data) + + # Step 10: Complete + cache.set(progress_key, {'stage': 'complete', 'percent': 100}) + + return JsonResponse({ + 'classification': classification_result, + 'keywords': keywords, + 'sentiment_data': sentiment_data, + 'priority_data': priority_data, + 'verdict': verdict if 'verdict' in locals() else "UNVERIFIED", + 'progress_key': progress_key + }) + + except Exception as e: + logger.error(f"Error in classification: {str(e)}") + return JsonResponse({ + 'error': str(e), + 'progress_key': progress_key + }, status=500) + + else: + form = ClassificationForm() + return render(request, 'classification.html', { + 'form': form, + 'result': {} + }) + +def determine_verdict(priority_data): + """Determine verdict based on priority data""" + # Extract priority flags from the data + if isinstance(priority_data, dict): + if "priority_flags" in priority_data: + priority_flags = priority_data["priority_flags"] + else: + # Assume the dictionary itself contains the flags + priority_flags = priority_data + else: + return "UNVERIFIED" + + # Get sentiment counts if available + sentiment_counts = {} + if "sentiment_counts" in priority_data: + sentiment_counts = priority_data["sentiment_counts"] + # Convert keys to strings if they're not already + if any(not isinstance(k, str) for k in sentiment_counts.keys()): + sentiment_counts = {str(k): v for k, v in sentiment_counts.items()} + + # Get priority score if available + priority_score = priority_data.get("priority_score", sum(priority_flags.values())) + + # Get claim and keywords + claim = priority_data.get("claim", "").lower() + keywords = priority_data.get("keywords", []) + keywords_lower = [k.lower() for k in keywords] + + # Check for specific claim patterns + is_azan_claim = any(word in claim for word in ["azan", "larang", "masjid", "pembesar suara"]) + is_religious_claim = any(word in claim for word in ["islam", "agama", "masjid", "surau", "sembahyang", "solat", "zakat"]) + + # Check for economic impact + economic_related = priority_flags.get("economic_impact", 0) == 1 + + # Check for government involvement + government_related = priority_flags.get("affects_government", 0) == 1 + + # Check for law-related content + law_related = priority_flags.get("law_related", 0) == 1 + + # Check for confusion potential + causes_confusion = priority_flags.get("cause_confusion", 0) == 1 + + # Check for negative sentiment dominance + negative_dominant = False + if sentiment_counts: + pos = int(sentiment_counts.get("positive", sentiment_counts.get("1", 0))) + neg = int(sentiment_counts.get("negative", sentiment_counts.get("2", 0))) + neu = int(sentiment_counts.get("neutral", sentiment_counts.get("0", 0))) + negative_dominant = neg > pos and neg > neu + + # Special case for azan claim (like the example provided) + if is_azan_claim and is_religious_claim and "larangan" in claim: + return "FALSE" # Claim about banning azan is false + + # Determine verdict based on multiple factors + if priority_score >= 7.0 and negative_dominant and (government_related or law_related): + return "FALSE" + elif priority_score >= 5.0 and causes_confusion: + return "PARTIALLY_TRUE" + elif priority_score <= 3.0 and not negative_dominant: + return "TRUE" + elif economic_related and government_related: + # Special case for economic policies by government + if negative_dominant: + return "FALSE" + elif causes_confusion: + return "PARTIALLY_TRUE" + else: + return "TRUE" + else: + return "UNVERIFIED" + +def image_profiling(request): + # import faiss + + result = None + image_with_labels = None + cropped_faces_base64 = [] + texts = None + proccessed = False + uploded_base64 = None + exifs = None + metadata = None + description = None + reverse_images = None + + if request.method == 'POST': + form = ImageUploadForm(request.POST, request.FILES) + if form.is_valid(): + proccessed = True + uploaded_image = request.FILES['image'] + + with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: + for chunk in uploaded_image.chunks(): + tmp.write(chunk) + tmp_path = tmp.name + + image = PILImage.open(uploaded_image) + image_np = numpy_lib.array(image.convert('RGB')) + exifs = devlab_image.extract_exif(tmp_path) + metadata = devlab_image.extract_metadata_exiftool(tmp_path) + description = devlab_image.generate_description_blip(tmp_path) + # reverse_images = devlab_image.reverse_search(tmp_path) + + buffered = io.BytesIO() + image.save(buffered, format="PNG") # or "JPEG", depending on your image format + img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") + uploded_base64 = f"data:image/png;base64,{img_str}" + + texts = devlab_image.extract_text_numpy(image_np) + + + # Detect face embeddings using DeepFace + face_embeddings = DeepFace.represent(image_np, model_name="Facenet", enforce_detection=False) + + + if not face_embeddings: + return "❌ No faces detected in the image." + + recognized_faces = {} + cropped_faces = [] + + for face_data in face_embeddings: + query_embedding = numpy_lib.array(face_data["embedding"], dtype=numpy_lib.float32).reshape(1, -1) + + results = devlab_image.query_embedding(query_embedding,1) + if results and len(results) > 0 and len(results[0]) > 0: + entity = results[0][0].entity + print(f"Entity: {entity}") # See what fields are present in the entity + + face_name = entity.get('name') if entity else 'Unknown' + fdescription = entity.get('short_description') if entity else '' + if fdescription is None: + fdescription = '' + + distance = round(results[0][0].distance, 4) + + if distance*100>95: + face_name = f"{face_name} (CLOSEST)" + # Store recognized face data + recognized_faces[f"clip_{len(recognized_faces) + 1}"] = { + "name": face_name, + "distance": distance, + "description": fdescription, + } + + # Face location for drawing rectangle and adding label + face_location = face_data["facial_area"] + x, y, w, h = face_location["x"], face_location["y"], face_location["w"], face_location["h"] + + # Draw rectangle and label on the image + # cv2.putText(image_np, label, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) + cv2.rectangle(image_np, (x, y), (x + w, y + h), (0, 255, 0), 2) + + # Crop the detected face and prepare it for displaying + cropped_face = image_np[y:y + h, x:x + w] + cropped_faces.append([cropped_face, face_name, distance, fdescription]) + + # label = f"{face_name} (Dist: {round(distance, 2)})" + + else: + print('No result found') + + + + # Convert the image with labels to base64 for HTML rendering + _, buffer = cv2.imencode('.png', image_np) + image_base64 = base64.b64encode(buffer).decode('utf-8') + + # Convert cropped faces to base64 for displaying in template + cropped_faces_base64 = [] + for face, face_name, distance, fdescription in cropped_faces: + _, buffer = cv2.imencode('.png', face) + face_base64 = base64.b64encode(buffer).decode('utf-8') + cropped_faces_base64.append([f"data:image/png;base64,{face_base64}",face_name, distance, fdescription]) + + # Prepare result for template rendering + result = recognized_faces + image_with_labels = f"data:image/png;base64,{image_base64}" + + + else: + form = ImageUploadForm() + + return render(request, 'image_profiling.html', { + 'form': form, + 'proccessed' : proccessed, + 'uploaded_base64': uploded_base64, + 'image_with_labels': image_with_labels, + 'cropped_faces': cropped_faces_base64, + 'texts': texts, + 'exifs': exifs, + 'metadata': metadata, + 'description': description, + 'reverse_images': reverse_images + }) + +# def detect_faces2(request): + # import faiss + # import numpy as np + # import pickle + # from deepface import DeepFace + # import cv2 + # import base64 + # from io import BytesIO + # from PIL import Image + # import os + + # result = None + # image_with_labels = None + # cropped_faces_base64 = [] + + # if request.method == 'POST': + # form = ImageUploadForm(request.POST, request.FILES) + # if form.is_valid(): + # uploaded_image = request.FILES['image'] + + # # Open the uploaded image with Pillow and convert to RGB + # image = Image.open(uploaded_image).convert('RGB') + # image_np = numpy_lib.array(image) + + # # Load FAISS index and metadata + # save_path = os.path.join(os.path.dirname(__file__), "deepface") + # try: + # index = faiss.read_index(save_path + "/faiss_hnsw_index.bin") + # with open(save_path + "/metadata.pkl", "rb") as f: + # names = pickle.load(f) + # except Exception as e: + # return f"Error loading FAISS index or metadata: {str(e)}" + + # # Set search parameters for better accuracy in FAISS + # index.hnsw.efSearch = 100 # Larger = better accuracy, but slower + + # # Detect face embeddings using DeepFace + # face_embeddings = DeepFace.represent(image_np, model_name="Facenet", enforce_detection=False) + + # if not face_embeddings: + # return "❌ No faces detected in the image." + + # recognized_faces = {} + # cropped_faces = [] + + # for face_data in face_embeddings: + # query_embedding = numpy_lib.array(face_data["embedding"], dtype=numpy_lib.float32).reshape(1, -1) + + # # Search for the closest matches in the FAISS index + # D, I = index.search(query_embedding, 1) # D = distances, I = indices + + # # Get the top match for this face + # face_name = names[I[0][0]] + # distance = D[0][0] + + # # Store recognized face data + # recognized_faces[f"clip_{len(recognized_faces) + 1}"] = { + # "name": face_name, + # "distance": round(distance, 4) + # } + + # # Face location for drawing rectangle and adding label + # face_location = face_data["facial_area"] + # x, y, w, h = face_location["x"], face_location["y"], face_location["w"], face_location["h"] + + # # Draw rectangle and label on the image + # # cv2.putText(image_np, label, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) + # cv2.rectangle(image_np, (x, y), (x + w, y + h), (0, 255, 0), 2) + + # # Crop the detected face and prepare it for displaying + # cropped_face = image_np[y:y + h, x:x + w] + # cropped_faces.append([cropped_face, face_name]) + + # label = f"{face_name} (Dist: {round(distance, 4)})" + + + + # # Convert the image with labels to base64 for HTML rendering + # _, buffer = cv2.imencode('.png', image_np) + # image_base64 = base64.b64encode(buffer).decode('utf-8') + + # # Convert cropped faces to base64 for displaying in template + # cropped_faces_base64 = [] + # for face,fname in cropped_faces: + # _, buffer = cv2.imencode('.png', face) + # face_base64 = base64.b64encode(buffer).decode('utf-8') + # cropped_faces_base64.append([f"data:image/png;base64,{face_base64}",fname]) + + # # Prepare result for template rendering + # result = recognized_faces + # image_with_labels = f"data:image/png;base64,{image_base64}" + + # else: + # form = ImageUploadForm() + + # return render(request, 'face_detection.html', { + # 'form': form, + # 'result': result, + # 'image_with_labels': image_with_labels, + # 'cropped_faces': cropped_faces_base64 # Pass the list of cropped faces to the template + # }) + + +def register_face(request): + from ai_api.library.devlab_image import DevLabImage + import os + from django.core.files.storage import FileSystemStorage + from django.conf import settings + + result = None + if request.method == 'POST': + form = RegisterFaceForm(request.POST) + person = request.POST.get("person", "").upper() + keywords = request.POST.get("keywords", "") + files = request.FILES.getlist('images') + + devlab_image = DevLabImage() + + + if files: + print('Upload manual') + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + upload_dir = os.path.join(project_root, 'people', person) + + print(f"Saving to: {upload_dir}") + os.makedirs(upload_dir, exist_ok=True) + + fs = FileSystemStorage(location=upload_dir) + + for file in files: + filename = fs.save(file.name, file) + file_url = fs.url(filename) + print(f"Saved: {file_url}") + devlab_image.extract_face( person, keywords) + else: + print('Download from Google') + devlab_image.register_person(person, keywords) + + + else: + form = RegisterFaceForm() + + + return render(request, 'register_face.html', { + 'form': form, + 'result': result, + }) + +def check_progress(request, key): + # print(f"getting progress key {key}") + progress = cache.get(key, {'stage': 'downloading', 'percent': 0}) + # print(progress) + return JsonResponse(progress) + +def handle_uploaded_file(file): + mime_type, _ = mimetypes.guess_type(file.name) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_audio_file: + output_audio_file = temp_audio_file.name + + if mime_type and mime_type.startswith('video'): + # Save video temporarily + with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.name)[-1]) as temp_video_file: + for chunk in file.chunks(): + temp_video_file.write(chunk) + video_path = temp_video_file.name + + # Extract audio using ffmpeg + command = [ + 'ffmpeg', + '-y', + '-i', video_path, + '-vn', # no video + '-acodec', 'pcm_s16le', # WAV format + '-ar', '16000', # 16 kHz sample rate + '-ac', '1', # Mono channel + output_audio_file + ] + + try: + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + print("FFmpeg stderr:", result.stderr.decode()) + + except subprocess.CalledProcessError as e: + logger.error(f"ffmpeg failed with error: {e.stderr.decode()}") + raise Exception(f"Audio extraction failed: {e.stderr.decode()}") + + # Clean up temporary video file + os.remove(video_path) + + else: + # If audio, save it directly + with open(output_audio_file, 'wb') as f: + for chunk in file.chunks(): + f.write(chunk) + + return output_audio_file + +def format_time(seconds): + # Convert seconds to WebVTT time format (hh:mm:ss.mmm) + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + ms = int((s - int(s)) * 1000) # Milliseconds + return f"{int(h):02}:{int(m):02}:{int(s):02}.{ms:03}" + +def generate_vtt(segments): + # Generate the VTT content from the Whisper segments + vtt_content = "WEBVTT\n\n" + + for segment in segments: + start_time = segment['start'] + end_time = segment['end'] + text = segment['text'] + + # Convert seconds to WebVTT time format + start_time_str = format_time(start_time) + end_time_str = format_time(end_time) + + vtt_content += f"{start_time_str} --> {end_time_str}\n{text}\n\n" + + return vtt_content + +def save_vtt(output_audio_file, vtt): + base_name = os.path.splitext(os.path.basename(output_audio_file))[0] + new_filename = base_name + ".vtt" + + final_path = os.path.join(settings.MEDIA_ROOT, 'vtt', new_filename) + os.makedirs(os.path.dirname(final_path), exist_ok=True) + + with open(final_path, "w", encoding="utf-8") as f: + f.write(vtt) + + return final_path + +def transcription(request): + + + transcription = None + error = None + progress_key = str(uuid.uuid4()) + + if request.method == "POST": + + progress_key = request.POST.get("progress_key", progress_key) + + model = globals.whisper_model + form = YouTubeURLForm(request.POST) + + #if form.is_valid(): + file = request.FILES.get('file') + if file: + # with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file: + # for chunk in file.chunks(): + # temp_file.write(chunk) + # output_audio_file = temp_file.name + output_audio_file = handle_uploaded_file(file) + if os.path.getsize(output_audio_file) == 0: + raise RuntimeError("FFmpeg produced an empty audio file.") + + print(f"transcribing : {output_audio_file}") + cache.set(progress_key, {'stage': 'transcribing', 'percent': 100}) + result = model.transcribe(output_audio_file,verbose=False) + vtt = generate_vtt(result['segments']) + vtt_file = save_vtt(output_audio_file, vtt) + + + else: + cache.set(progress_key, {'stage': 'downloading', 'percent': 0}) + ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') + + def progress_hook(d): + # print(f"status {d['status']}") + if d['status'] == 'downloading': + # print(d) + percent_str = d.get('_percent_str', '0%').strip() + clean_str = ansi_escape.sub('', percent_str).strip() + # print(f"clean percent_str: {repr(clean_str)}") # e.g. '100.0%' + + try: + match = re.search(r'(\d+(?:\.\d+)?)', clean_str) + if match: + percent = float(match.group(1)) + else: + print("❌ Regex didn't match!") + percent = 0 + except Exception as e: + print(f"❌ Error parsing percent: {e}") + percent = 0 + + # print(f"✅ current progress for {progress_key} is: {percent}") + cache.set(progress_key, {'stage': 'downloading', 'percent': percent}) + + url = request.POST.get('url') + unique_id = str(uuid.uuid4()) + temp_dir = tempfile.gettempdir() + base_filename = f"temp_{unique_id}" + download_path = f"{temp_dir}/{base_filename}.%(ext)s" + # print(f"download_path: {download_path}") + output_audio_file = f"{temp_dir}/{base_filename}.mp3" + + ydl_opts = { + 'format': 'bestaudio/best', + 'outtmpl': download_path, # No fixed extension! + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192', + }], + 'progress_hooks': [progress_hook], + 'quiet': True, + 'no_warnings': True, + 'noplaylist': True, + } + print(f"downloading : {url}") + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + print(f"transcribing : {output_audio_file}") + cache.set(progress_key, {'stage': 'transcribing', 'percent': 100}) + result = model.transcribe(output_audio_file,verbose=False) + vtt = generate_vtt(result['segments']) + vtt_file = save_vtt(output_audio_file,vtt) + except Exception as e: + error = str(e) + + + # transcription = result['text'] + + # audio = AudioSegment.from_file(output_audio_file) + # chunk_length_ms = 60 * 1000 # 1-minute chunks + # chunks = [audio[i:i+chunk_length_ms] for i in range(0, len(audio), chunk_length_ms)] + # results = [] + # total_chunks = len(chunks) + # cache.set(progress_key, {'stage': 'transcribing', 'percent': 0}) + + # for i, chunk in enumerate(chunks): + # temp_filename = f"temp_chunk_{i}.wav" + # chunk.export(temp_filename, format="wav") + + # result = model.transcribe(temp_filename, verbose=False) + # results.append(result["text"]) + + # os.remove(temp_filename) + + # # Update progress + # percent = int((i + 1) / total_chunks * 100) + # cache.set(progress_key, {'stage': 'transcribing', 'percent': percent}) + + # # Combine all chunk texts + # transcription = "\n".join(results) + + + cache.set(progress_key, {'stage': 'done', 'percent': 100}) + + filename = os.path.basename(output_audio_file) + final_path = os.path.join(settings.MEDIA_ROOT, 'uploads', filename) + os.makedirs(os.path.dirname(final_path), exist_ok=True) + shutil.move(output_audio_file, final_path) + + # Public URL + + + file_url = settings.MEDIA_URL + 'uploads/' + filename + audio_html = f'' + + + return JsonResponse({'text': result['text'], 'segments': result['segments'], 'audio_file': audio_html }) + # if os.path.exists(output_audio_file): + # os.remove(output_audio_file) + + + # return render(request, 'transcription.html', { + # 'form': form, + # 'transcription': transcription, + # 'error': error, + # 'progress_key': progress_key, + # }) + + else: + form = TranscribeForm() + + return render(request, 'transcription.html', { + 'form': form, + 'transcription': transcription, + 'error': error, + 'progress_key': progress_key, + }) + +def csv_to_html_table(filepath): + def is_valid_url(url): + # URL pattern matching - must start with http:// or https:// + url_pattern = re.compile( + r'^https?://' # must start with http:// or https:// + r'([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+' # domain + r'[a-zA-Z]{2,}' # TLD + r'(/[a-zA-Z0-9-._~:/?#[\]@!$&\'()*+,;=]*)?$' # path and query + ) + return bool(url_pattern.match(url)) + + html = '' + with open(filepath, newline='') as csvfile: + reader = csv.reader(csvfile) + for i, row in enumerate(reader): + if i == 0: + html += '' + html += "" + "".join(f"" for col in row) + "" + html += '' + else: + html += "" + "".join( + f'' if is_valid_url(col) else f"" + for col in row + ) + "" + html += "
{col}
{col}{col}
" + return html \ No newline at end of file diff --git a/ai_api/widgets.py b/ai_api/widgets.py new file mode 100644 index 0000000000000000000000000000000000000000..56af53114ad72ac18e008b9cc443718d8518e60c --- /dev/null +++ b/ai_api/widgets.py @@ -0,0 +1,5 @@ +from django.forms.widgets import ClearableFileInput + +class MultipleFileInput(ClearableFileInput): + allow_multiple_selected = True + diff --git a/csv_people.py b/csv_people.py new file mode 100644 index 0000000000000000000000000000000000000000..59a2f5ba11b9ea87b716349d141680db80377cb4 --- /dev/null +++ b/csv_people.py @@ -0,0 +1,20 @@ +import os +import csv + +# Path to the folder you want to scan +folder_path = 'people' + +# Get all subfolder names +subfolders = [f.name for f in os.scandir(folder_path) if f.is_dir()] + +# Path to the output CSV file +csv_file = 'subfolders.csv' + +# Write the subfolder names to the CSV file +with open(csv_file, mode='w', newline='') as file: + writer = csv.writer(file) + writer.writerow(['Subfolder Name']) # Write the header + for subfolder in subfolders: + writer.writerow([subfolder]) # Write each subfolder name + +print(f"Subfolder names have been written to {csv_file}") diff --git a/delete_milvus.py b/delete_milvus.py new file mode 100644 index 0000000000000000000000000000000000000000..fb6ebd3b8b37f7dac5254ef9fb5bcc7f6ce8caaa --- /dev/null +++ b/delete_milvus.py @@ -0,0 +1,29 @@ +from pymilvus import Collection, connections +from dotenv import load_dotenv +import os +load_dotenv() + + +milvus_host = os.getenv("MILVUS_HOST", "localhost") # default localhost +milvus_port = os.getenv("MILVUS_PORT", "19530") # default 19530 + +connections.connect("default", host=milvus_host, port=int(milvus_port)) + + +# Now, connect to the collection +collection = Collection("faces") + +# Query the collection to find entries where the 'name' field is empty or None +query = 'name == "YAB DATO SERI ANWAR IBRAHIM"' # Looking for entities where 'name' is empty + +# Perform the query to find entities with empty 'name' fields +results = collection.query(query, output_fields=["id", "name"]) + +# Check and delete entities with empty 'name' +if results: + ids_to_delete = [str(result["id"]) for result in results] + id_expr = f"id in [{', '.join(ids_to_delete)}]" + collection.delete(expr=id_expr) + print(f"✅ Deleted entities: {ids_to_delete}") +else: + print("❌ No entities found for deletion.") \ No newline at end of file diff --git a/devlab_next/.gitignore b/devlab_next/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..8cd1372e447b34bf5f680773bd592a24dff8dfc0 --- /dev/null +++ b/devlab_next/.gitignore @@ -0,0 +1,68 @@ +# Python bytecode files +*.pyc +*.pyo +*.pyd +__pycache__/ + +# Virtual environment +venv/ +env/ + +# Distribution / packaging +*.egg +*.egg-info +dist/ +build/ +*.whl + +# IDE files +.idea/ +.vscode/ + +# Jupyter Notebook files +.ipynb_checkpoints + +# PyInstaller +*.manifest +*.spec + +# Test and coverage reports +.coverage +*.coveragerc +nosetests.xml +coverage.xml +*.coveralls.yml + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pytest +.cache/ + +# Sphinx documentation +docs/_build/ + +# pytest and flake8 +*.log + +# VS Code settings +.vscode/ + +# Django secrets +*.env + +# Flask instance folder +instance/ + +# PyCharm project files +.idea/ + +# Other Python-related files +*.bak +*.swp +*.swo +ddet_classification/ +.DS_Store +.pkl \ No newline at end of file diff --git a/devlab_next/__init__.py b/devlab_next/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/devlab_next/asgi.py b/devlab_next/asgi.py new file mode 100644 index 0000000000000000000000000000000000000000..abdb7696676318b19a5a9fcd6f07c8b9e37ec1e6 --- /dev/null +++ b/devlab_next/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for devlab_next project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devlab_next.settings') + +application = get_asgi_application() diff --git a/devlab_next/settings.py b/devlab_next/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..a083c30194260295bf831d79c9179405ef35d90f --- /dev/null +++ b/devlab_next/settings.py @@ -0,0 +1,166 @@ +""" +Django settings for devlab_next project. + +Generated by 'django-admin startproject' using Django 4.2.7. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-5a87e9*^s30hb+%+h@t^06493w2tpv7w6%+(0!#iu77b%*8=#i' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['127.0.0.1','fctestbed.bernama.com','localhost'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + # 'ai_api', + 'ai_api.apps.AiApiConfig', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + # 'ai_api.middleware.HMACAuthMiddleware' +] + +ROOT_URLCONF = 'devlab_next.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'devlab_next.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +# DATABASES = { +# 'default': { +# 'ENGINE': 'django.db.backends.sqlite3', +# 'NAME': BASE_DIR / 'db.sqlite3', +# } +# } + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("DB_NAME", "factcheckapidb"), + "USER": os.environ.get("DB_USER", "postgres"), + "PASSWORD": os.environ.get("DB_PASSWORD", "postgres"), + "HOST": os.environ.get("DB_HOST", "127.0.0.1"), + "PORT": os.environ.get("DB_PORT", "5432"), + } +} + + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = '/static/' +# STATIC_ROOT = BASE_DIR / 'static/' + +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'static'), +] + + + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # In-memory + 'LOCATION': 'progress-cache', + } +} + + + + + + + + + diff --git a/devlab_next/urls.py b/devlab_next/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..7f8d4ea731dc35f0270d1a1df8ee7e79f84eb58e --- /dev/null +++ b/devlab_next/urls.py @@ -0,0 +1,33 @@ +""" +URL configuration for devlab_next project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +import os + +admin.site.site_header = "BERNAMA Fact Check" +admin.site.site_title = "BERNAMA Fact Check Portal" +admin.site.index_title = "Dashboard" + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('ai_api.urls')), + path('api/v1/', include('ai_api.api_urls')), +]+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/devlab_next/wsgi.py b/devlab_next/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..776a7aa621dd798c17fa1934c059e2be56526477 --- /dev/null +++ b/devlab_next/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for devlab_next project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devlab_next.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..9ccf372414ac458e8c9f7199683c82d66edce8a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,95 @@ +version: '3.5' + +services: + web: + build: . + container_name: django_app + mem_limit: 16g + command: gunicorn devlab_next.wsgi:application --bind 0.0.0.0:8000 --workers 3 --log-level debug + volumes: + - .:/app + ports: + - "8000:8000" + depends_on: + - milvus-standalone + environment: + - DJANGO_SETTINGS_MODULE=devlab_next.settings + - TF_CPP_MIN_LOG_LEVEL=2 + networks: + - milvus_network + + milvus-standalone: + container_name: milvus + image: milvusdb/milvus:v2.5.8 + command: ["milvus", "run", "standalone"] + security_opt: + - seccomp:unconfined + restart: always + ports: + - "19530:19530" # gRPC + - "19121:19121" # HTTP (correct health port) + volumes: + - ./volumes/milvus:/var/lib/milvus + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:19121/healthz"] + interval: 30s + start_period: 90s + timeout: 20s + retries: 3 + depends_on: + - etcd + - minio + environment: + ETCD_ENDPOINTS: etcd:2379 + MINIO_ADDRESS: minio:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + MILVUS_LOG_LEVEL: debug + networks: + - milvus_network + + etcd: + image: quay.io/coreos/etcd:v3.5.18 + container_name: etcd + command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd + environment: + - ETCD_AUTO_COMPACTION_MODE=revision + - ETCD_AUTO_COMPACTION_RETENTION=1000 + - ETCD_QUOTA_BACKEND_BYTES=4294967296 + - ETCD_SNAPSHOT_COUNT=50000 + volumes: + - ./volumes/etcd:/etcd + healthcheck: + test: ["CMD", "etcdctl", "endpoint", "health"] + interval: 30s + timeout: 20s + retries: 3 + ports: + - "2379:2379" + - "2380:2380" + networks: + - milvus_network + + minio: + container_name: minio + image: minio/minio:RELEASE.2023-03-20T20-16-18Z + environment: + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + command: minio server /minio_data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - ./volumes/minio:/minio_data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - milvus_network + +networks: + milvus_network: + driver: bridge diff --git a/download_people.py b/download_people.py new file mode 100644 index 0000000000000000000000000000000000000000..e737628f24d584684a3c943ab20f05361752c8e5 --- /dev/null +++ b/download_people.py @@ -0,0 +1,14 @@ +from ai_api.library.devlab_image import DevLabImage +import csv + +devlab_image = DevLabImage() + +# # Open and read the CSV file +with open("subfolders.csv", mode="r", encoding="utf-8") as file: + reader = csv.reader(file) + for row in reader: + print(row[0], row[1]) # Each row is a list + devlab_image.register_person(row[0],row[1]) + +# field_value = input("Enter the name: ") +# devlab_image.download_person_images(field_value.upper()) \ No newline at end of file diff --git a/list_faces.py b/list_faces.py new file mode 100644 index 0000000000000000000000000000000000000000..430483da33d1babcae71e235671c117a5d295865 --- /dev/null +++ b/list_faces.py @@ -0,0 +1,23 @@ +from pymilvus import Collection, connections +from dotenv import load_dotenv +import os +load_dotenv() + + +milvus_host = os.getenv("MILVUS_HOST", "localhost") # default localhost +milvus_port = os.getenv("MILVUS_PORT", "19530") # default 19530 + +connections.connect("default", host=milvus_host, port=int(milvus_port)) + +# Now, connect to the collection +collection = Collection("faces") + +# Query expression that retrieves all documents with a non-null 'id' (or use any valid field) +query = "id IS NOT NULL" # Valid query expression to fetch all documents + +# Retrieve all documents, adjust fields based on your collection schema +results = collection.query(query, output_fields=["id", "name"]) + +# Print all results +for result in results: + print(f"ID: {result['id']}, Name: {result.get('name', 'N/A')}") diff --git a/manage.py b/manage.py new file mode 100755 index 0000000000000000000000000000000000000000..aba5054621cc4264eb43a5cda314adc7a70ede9c --- /dev/null +++ b/manage.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" +os.environ["CUDA_VISIBLE_DEVICES"] = "-1" + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devlab_next.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/rebuild_faces.py b/rebuild_faces.py new file mode 100644 index 0000000000000000000000000000000000000000..a4b40bd56e528cf18393e1c8ed64fa4df6748641 --- /dev/null +++ b/rebuild_faces.py @@ -0,0 +1,19 @@ +from pymilvus import connections, utility +from dotenv import load_dotenv +import os +load_dotenv() + + +milvus_host = os.getenv("MILVUS_HOST", "localhost") # default localhost +milvus_port = os.getenv("MILVUS_PORT", "19530") # default 19530 + +connections.connect("default", host=milvus_host, port=int(milvus_port)) + + +# Delete the collection +collection_name = "faces" +if utility.has_collection(collection_name): + utility.drop_collection(collection_name) + print(f"Collection '{collection_name}' deleted.") +else: + print(f"Collection '{collection_name}' does not exist.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..faa4434beabd5d908be98a83c25f120fccbe4c38 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,119 @@ +absl-py==2.2.2 +asgiref==3.8.1 +astunparse==1.6.3 +async-timeout==5.0.1 +attrs==25.3.0 +beautifulsoup4==4.13.3 +blinker==1.9.0 +bs4==0.0.2 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.1.8 +deepface==0.0.93 +Django==4.2.20 +django-redis==5.4.0 +djangorestframework==3.16.0 +easyocr==1.7.2 +exceptiongroup==1.2.2 +filelock==3.18.0 +fire==0.7.0 +Flask==3.1.0 +flask-cors==5.0.1 +flatbuffers==25.2.10 +fsspec==2025.3.2 +gast==0.6.0 +gdown==5.2.0 +google-pasta==0.2.0 +grpcio==1.67.1 +gunicorn==23.0.0 +h11==0.14.0 +h5py==3.13.0 +huggingface-hub==0.30.2 +idna==3.10 +imageio==2.37.0 +importlib_metadata==8.6.1 +itsdangerous==2.2.0 +Jinja2==3.1.6 +keras==3.9.2 +lazy_loader==0.4 +libclang==18.1.1 +llvmlite==0.43.0 +Markdown==3.8 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +milvus-lite==2.4.12 +ml_dtypes==0.5.1 +more-itertools==10.6.0 +mpmath==1.3.0 +mtcnn==0.1.1 +namex==0.0.8 +networkx==3.2.1 +ninja==1.11.1.4 +numba==0.60.0 +numpy==1.26.4 +openai-whisper==20240930 +opencv-python==4.11.0.86 +opencv-python-headless==4.11.0.86 +opt_einsum==3.4.0 +optree==0.15.0 +outcome==1.3.0.post0 +packaging==24.2 +pandas==2.2.3 +pillow==11.2.1 +protobuf==5.29.4 +pyclipper==1.3.0.post6 +pydub==0.25.1 +Pygments==2.19.1 +pymilvus==2.5.6 +PySocks==1.7.1 +python-bidi==0.6.6 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 +pytz==2025.2 +PyYAML==6.0.2 +redis==5.2.1 +regex==2024.11.6 +requests==2.32.3 +retina-face==0.0.17 +rich==14.0.0 +safetensors==0.5.3 +scikit-image==0.24.0 +scipy==1.13.1 +selenium==4.31.0 +shapely==2.0.7 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.6 +sqlparse==0.5.3 +sympy==1.13.1 +tensorboard==2.19.0 +tensorboard-data-server==0.7.2 +tensorflow==2.19.0 +tensorflow-io-gcs-filesystem==0.37.1 +termcolor==3.0.1 +tf_keras==2.19.0 +tifffile==2024.8.30 +tiktoken==0.9.0 +tokenizers==0.21.1 +torch==2.6.0 +torchvision==0.21.0 +tqdm==4.67.1 +transformers==4.51.2 +trio==0.29.0 +trio-websocket==0.12.2 +typing_extensions==4.13.2 +tzdata==2025.2 +ujson==5.10.0 +urllib3==2.4.0 +webdriver-manager==4.0.2 +websocket-client==1.8.0 +Werkzeug==3.1.3 +wrapt==1.17.2 +wsproto==1.2.0 +yt-dlp==2025.3.31 +zipp==3.21.0 +Django>=4.2 +gunicorn +psycopg2-binary diff --git a/static/admin/css/autocomplete.css b/static/admin/css/autocomplete.css new file mode 100644 index 0000000000000000000000000000000000000000..69c94e73477467d7a9376ebbdf20955c2defb9cb --- /dev/null +++ b/static/admin/css/autocomplete.css @@ -0,0 +1,275 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/static/admin/css/base.css b/static/admin/css/base.css new file mode 100644 index 0000000000000000000000000000000000000000..93db7d062a91bafc0b53a51aac3a6cad1eb778c0 --- /dev/null +++ b/static/admin/css/base.css @@ -0,0 +1,1145 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: var(--primary); + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + text-decoration: none; +} + +a.section:focus, a.section:hover { + text-decoration: underline; +} + +/* GLOBAL DEFAULTS */ + +p, ol, ul, dl { + margin: .2em 0 .8em 0; +} + +p { + padding: 0; + line-height: 140%; +} + +h1,h2,h3,h4,h5 { + font-weight: bold; +} + +h1 { + margin: 0 0 20px; + font-weight: 300; + font-size: 1.25rem; + color: var(--body-quiet-color); +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +ul > li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url(../img/sorting-icons.svg) 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { + border-color: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url(../img/icon-viewlink.svg) 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url(../img/icon-addlink.svg) 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url(../img/icon-changelink.svg) 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url(../img/icon-deletelink.svg) 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +#footer { + clear: both; + padding: 10px; +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.paginator input { + margin-left: auto; +} + +.base-svgs { + display: none; +} diff --git a/static/admin/css/changelists.css b/static/admin/css/changelists.css new file mode 100644 index 0000000000000000000000000000000000000000..a7545131a3917bb3e56774126bc78ab2b97e68a3 --- /dev/null +++ b/static/admin/css/changelists.css @@ -0,0 +1,328 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#changelist table { + width: 100%; +} + +.change-list .hiddenfields { display:none; } + +.change-list .filtered table { + border-right: none; +} + +.change-list .filtered { + min-height: 400px; +} + +.change-list .filtered .results, .change-list .filtered .paginator, +.filtered #toolbar, .filtered div.xfull { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* CHANGELIST TABLES */ + +#changelist table thead th { + padding: 0; + white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th.action-checkbox-column { + width: 1.5em; + text-align: center; +} + +#changelist table tbody td.action-checkbox { + text-align: center; +} + +#changelist table tfoot { + color: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/static/admin/css/dark_mode.css b/static/admin/css/dark_mode.css new file mode 100644 index 0000000000000000000000000000000000000000..6d08233aaebcd6708c5c5fbc55738610cd5e911d --- /dev/null +++ b/static/admin/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/static/admin/css/dashboard.css b/static/admin/css/dashboard.css new file mode 100644 index 0000000000000000000000000000000000000000..242b81a45f8540831ef7c40536f79530b6652c62 --- /dev/null +++ b/static/admin/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/static/admin/css/forms.css b/static/admin/css/forms.css new file mode 100644 index 0000000000000000000000000000000000000000..9a8dad08cce71e8f4842b9c8b6d6d9d92b4f3298 --- /dev/null +++ b/static/admin/css/forms.css @@ -0,0 +1,534 @@ +@import url('widgets.css'); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.form-row img, .form-row input { + vertical-align: middle; +} + +.form-row label input[type="checkbox"] { + margin-top: 0; + vertical-align: 0; +} + +form .form-row p { + padding-left: 0; +} + +.flex-container { + display: flex; +} + +.form-multiline { + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + min-width: 160px; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h3 { + margin: 0; + color: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group ul.tools a.add, +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + background: url(../img/icon-addlink.svg) 0 1px no-repeat; + padding-left: 16px; + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url(../img/search.svg); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/static/admin/css/login.css b/static/admin/css/login.css new file mode 100644 index 0000000000000000000000000000000000000000..389772f5bcec0fc5dce54cab9afdf5a8545bb713 --- /dev/null +++ b/static/admin/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/static/admin/css/nav_sidebar.css b/static/admin/css/nav_sidebar.css new file mode 100644 index 0000000000000000000000000000000000000000..f76e6ce485911e70589d28d63e01dad0c82331ad --- /dev/null +++ b/static/admin/css/nav_sidebar.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/static/admin/css/responsive.css b/static/admin/css/responsive.css new file mode 100644 index 0000000000000000000000000000000000000000..1d0a188f2ce17c828774bd8478ebac35fa2b0362 --- /dev/null +++ b/static/admin/css/responsive.css @@ -0,0 +1,999 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + .flex-container.checkbox-row { + flex-flow: row; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + min-width: auto; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/static/admin/css/responsive_rtl.css b/static/admin/css/responsive_rtl.css new file mode 100644 index 0000000000000000000000000000000000000000..31dc8ff7db1c9a78a246ff78c274e3ad66e85d6a --- /dev/null +++ b/static/admin/css/responsive_rtl.css @@ -0,0 +1,84 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group ul.tools a.add, + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } + [dir="rtl"] .aligned .vCheckboxLabel { + padding: 1px 5px 0 0; + } +} diff --git a/static/admin/css/rtl.css b/static/admin/css/rtl.css new file mode 100644 index 0000000000000000000000000000000000000000..c349a939ed248eb08862ad4c1c4c3f110045e0e3 --- /dev/null +++ b/static/admin/css/rtl.css @@ -0,0 +1,298 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +.paginator .end { + margin-left: 6px; + margin-right: 0; +} + +.paginator input { + margin-left: 0; + margin-right: auto; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} diff --git a/static/admin/css/vendor/select2/LICENSE-SELECT2.md b/static/admin/css/vendor/select2/LICENSE-SELECT2.md new file mode 100644 index 0000000000000000000000000000000000000000..8cb8a2b12cb7207f971f93f5e3f6fcfff8863d4c --- /dev/null +++ b/static/admin/css/vendor/select2/LICENSE-SELECT2.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +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. diff --git a/static/admin/css/vendor/select2/select2.css b/static/admin/css/vendor/select2/select2.css new file mode 100644 index 0000000000000000000000000000000000000000..750b3207aeb800f8e76420253229bd1c5c135d0d --- /dev/null +++ b/static/admin/css/vendor/select2/select2.css @@ -0,0 +1,481 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } + .select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-selection--single .select2-selection__clear { + position: relative; } + .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } + .select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline-block; + overflow: hidden; + padding-left: 8px; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-search--inline { + float: left; } + .select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + padding: 0; } + .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + padding: 6px; + user-select: none; + -webkit-user-select: none; } + .select2-results__option[aria-selected] { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } + .select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } + .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + .select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } + .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; } + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } + .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } + .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered li { + list-style: none; } + .select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + padding: 1px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid black 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option[role=group] { + padding: 0; } + +.select2-container--default .select2-results__option[aria-disabled=true] { + color: #999; } + +.select2-container--default .select2-results__option[aria-selected=true] { + background-color: #ddd; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; } + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: #5897fb; + color: white; } + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + .select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-right: 10px; } + .select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } + .select2-container--classic .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; } + .select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--multiple .select2-selection__rendered { + list-style: none; + margin: 0; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + color: #888; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option[role=group] { + padding: 0; } + +.select2-container--classic .select2-results__option[aria-disabled=true] { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/static/admin/css/vendor/select2/select2.min.css b/static/admin/css/vendor/select2/select2.min.css new file mode 100644 index 0000000000000000000000000000000000000000..7c18ad59dfc37f537cfd158e9222062fa9196e37 --- /dev/null +++ b/static/admin/css/vendor/select2/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/static/admin/css/widgets.css b/static/admin/css/widgets.css new file mode 100644 index 0000000000000000000000000000000000000000..1104e8b14a6eea13bebd0ee36a68aafc3ea382a5 --- /dev/null +++ b/static/admin/css/widgets.css @@ -0,0 +1,604 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + width: 800px; + float: left; + display: flex; +} + +.selector select { + width: 380px; + height: 17.2em; + flex: 1 0 auto; +} + +.selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + +.selector-available h2, .selector-chosen h2 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen h2 { + background: var(--primary); + color: var(--header-link-color); +} + +.selector .selector-available h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; + min-width: auto; +} + +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; +} + +.selector ul.selector-chooser { + align-self: center; + width: 22px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0 5px; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 16px; + height: 16px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; +} + +.active.selector-add, .active.selector-remove { + opacity: 1; +} + +.active.selector-add:hover, .active.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; +} + +a.selector-chooseall, a.selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 1px auto 3px; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: var(--link-fg); +} + +a.active.selector-chooseall, a.active.selector-clearall { + opacity: 1; +} + +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + cursor: pointer; +} + +a.selector-chooseall { + padding: 0 18px 0 0; + background: url(../img/selector-icons.svg) right -160px no-repeat; + cursor: default; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -176px; +} + +a.selector-clearall { + padding: 0 0 0 18px; + background: url(../img/selector-icons.svg) 0 -128px no-repeat; + cursor: default; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + height: 22px; + width: 50px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url(../img/selector-icons.svg) 0 -32px no-repeat; + cursor: default; +} + +.stacked .active.selector-add { + background-position: 0 -32px; + cursor: pointer; +} + +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url(../img/selector-icons.svg) 0 0 no-repeat; + cursor: default; +} + +.stacked .active.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; + cursor: pointer; +} + +.selector .help-icon { + background: url(../img/icon-unknown.svg) 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url(../img/search.svg) 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url(../img/icon-clock.svg) 0 0 no-repeat; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -16px; +} + +.datetimeshortcuts .date-icon { + background: url(../img/icon-calendar.svg) 0 0 no-repeat; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -16px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--primary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + +.calendarnav-next { + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: #eee; + border-top: 1px solid var(--border-color); + color: var(--body-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: #ddd; +} + +.calendar-cancel a { + color: black; + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url(../img/inline-delete.svg) 0 0 no-repeat; + width: 16px; + height: 16px; + border: 0px none; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ +} + +.related-widget-wrapper-link { + opacity: 0.3; +} + +.related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { + opacity: 1; +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/static/admin/img/LICENSE b/static/admin/img/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..a4faaa1dfa226ac68c6a7898f7161d0e2956dcb3 --- /dev/null +++ b/static/admin/img/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +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. diff --git a/static/admin/img/README.txt b/static/admin/img/README.txt new file mode 100644 index 0000000000000000000000000000000000000000..4eb2e492a9be5f85a3b2cf039257b500273c2bc0 --- /dev/null +++ b/static/admin/img/README.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (http://fontawesome.io/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/static/admin/img/calendar-icons.svg b/static/admin/img/calendar-icons.svg new file mode 100644 index 0000000000000000000000000000000000000000..dbf21c39d238c60288c0206a3969eb8a50d3a278 --- /dev/null +++ b/static/admin/img/calendar-icons.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/admin/img/gis/move_vertex_off.svg b/static/admin/img/gis/move_vertex_off.svg new file mode 100644 index 0000000000000000000000000000000000000000..228854f3b00be502dbb2deed17020bbfe915556d --- /dev/null +++ b/static/admin/img/gis/move_vertex_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/admin/img/gis/move_vertex_on.svg b/static/admin/img/gis/move_vertex_on.svg new file mode 100644 index 0000000000000000000000000000000000000000..96b87fdd708ef19fc3c6e466c44d7c212efa1d14 --- /dev/null +++ b/static/admin/img/gis/move_vertex_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/admin/img/icon-addlink.svg b/static/admin/img/icon-addlink.svg new file mode 100644 index 0000000000000000000000000000000000000000..e004fb162633a3cab16d650492698785194cb66f --- /dev/null +++ b/static/admin/img/icon-addlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-alert.svg b/static/admin/img/icon-alert.svg new file mode 100644 index 0000000000000000000000000000000000000000..e51ea83f5bb0e420a11f6b91c18654d0a227da97 --- /dev/null +++ b/static/admin/img/icon-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-calendar.svg b/static/admin/img/icon-calendar.svg new file mode 100644 index 0000000000000000000000000000000000000000..97910a9949126a13793506efed884f378fc8449a --- /dev/null +++ b/static/admin/img/icon-calendar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/admin/img/icon-changelink.svg b/static/admin/img/icon-changelink.svg new file mode 100644 index 0000000000000000000000000000000000000000..bbb137aa0866379ef81fd5a0e8a6d3207628b0ac --- /dev/null +++ b/static/admin/img/icon-changelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-clock.svg b/static/admin/img/icon-clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..bf9985d3f44610bd43d9daada9876db12100d504 --- /dev/null +++ b/static/admin/img/icon-clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/admin/img/icon-deletelink.svg b/static/admin/img/icon-deletelink.svg new file mode 100644 index 0000000000000000000000000000000000000000..4059b15544994e5e73e9b219c31627055dfa17bc --- /dev/null +++ b/static/admin/img/icon-deletelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-no.svg b/static/admin/img/icon-no.svg new file mode 100644 index 0000000000000000000000000000000000000000..2e0d3832c9299c3994f627cd64ed0341a5da7b14 --- /dev/null +++ b/static/admin/img/icon-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-unknown-alt.svg b/static/admin/img/icon-unknown-alt.svg new file mode 100644 index 0000000000000000000000000000000000000000..1c6b99fc0946c3f41df99174e3621eb88d3c23e7 --- /dev/null +++ b/static/admin/img/icon-unknown-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-unknown.svg b/static/admin/img/icon-unknown.svg new file mode 100644 index 0000000000000000000000000000000000000000..50b4f97276b46f2d3cd7102aaede3c526d3887b6 --- /dev/null +++ b/static/admin/img/icon-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-viewlink.svg b/static/admin/img/icon-viewlink.svg new file mode 100644 index 0000000000000000000000000000000000000000..a1ca1d3f4e246eb6b7bc4bc078b0cce37cc27e42 --- /dev/null +++ b/static/admin/img/icon-viewlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/icon-yes.svg b/static/admin/img/icon-yes.svg new file mode 100644 index 0000000000000000000000000000000000000000..5883d877e89b89d42fa121725ae7b726dbfa5f50 --- /dev/null +++ b/static/admin/img/icon-yes.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/inline-delete.svg b/static/admin/img/inline-delete.svg new file mode 100644 index 0000000000000000000000000000000000000000..17d1ad67cdcca17f6ddcdbb4edf062a9f2b49b60 --- /dev/null +++ b/static/admin/img/inline-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/search.svg b/static/admin/img/search.svg new file mode 100644 index 0000000000000000000000000000000000000000..c8c69b2acc1cd0104aa9fbcd61893d9eeace8f25 --- /dev/null +++ b/static/admin/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/selector-icons.svg b/static/admin/img/selector-icons.svg new file mode 100644 index 0000000000000000000000000000000000000000..926b8e21b524c4bdd8a2f094d7f8b3043196112a --- /dev/null +++ b/static/admin/img/selector-icons.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/admin/img/sorting-icons.svg b/static/admin/img/sorting-icons.svg new file mode 100644 index 0000000000000000000000000000000000000000..7c31ec91145538b8f985d8991489b076daec514c --- /dev/null +++ b/static/admin/img/sorting-icons.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/static/admin/img/tooltag-add.svg b/static/admin/img/tooltag-add.svg new file mode 100644 index 0000000000000000000000000000000000000000..1ca64ae5b08ed18efda27c9a58a8496d31afac2a --- /dev/null +++ b/static/admin/img/tooltag-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/img/tooltag-arrowright.svg b/static/admin/img/tooltag-arrowright.svg new file mode 100644 index 0000000000000000000000000000000000000000..b664d61937be6fa51d59453a7c21228b5d2ace7a --- /dev/null +++ b/static/admin/img/tooltag-arrowright.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/admin/js/SelectBox.js b/static/admin/js/SelectBox.js new file mode 100644 index 0000000000000000000000000000000000000000..3db4ec7fa6612625ec731a2103a489bcdbce4637 --- /dev/null +++ b/static/admin/js/SelectBox.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/static/admin/js/SelectFilter2.js b/static/admin/js/SelectFilter2.js new file mode 100644 index 0000000000000000000000000000000000000000..9a4e0a3a91bbd381cc12b71f66f4c6648c97806a --- /dev/null +++ b/static/admin/js/SelectFilter2.js @@ -0,0 +1,283 @@ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

, because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

or
+ const selector_div = quickElement('div', from_box.parentNode); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
+ const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); + quickElement( + 'span', title_available, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of available %s. You may choose some by ' + + 'selecting them in the box below and then clicking the ' + + '"Choose" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); + choose_all.className = 'selector-chooseall'; + + //
    + const selector_chooser = quickElement('ul', selector_div); + selector_chooser.className = 'selector-chooser'; + const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); + add_link.className = 'selector-add'; + const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); + remove_link.className = 'selector-remove'; + + //
    + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); + selector_chosen.className = 'selector-chosen'; + const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); + quickElement( + 'span', title_chosen, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of chosen %s. You may remove some by ' + + 'selecting them in the box below and then clicking the ' + + '"Remove" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; + + const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); + to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); + clear_all.className = 'selector-clearall'; + + from_box.name = from_box.name + '_old'; + + // Set up the JavaScript event handlers for the select box filter interface + const move_selection = function(e, elem, move_func, from, to) { + if (elem.classList.contains('active')) { + move_func(from, to); + SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + } + e.preventDefault(); + }; + choose_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); + }); + add_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); + }); + remove_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); + }); + clear_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); + }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); + filter_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); + }); + filter_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_from'); + }); + filter_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); + }); + selector_div.addEventListener('change', function(e) { + if (e.target.tagName === 'SELECT') { + SelectFilter.refresh_icons(field_id); + } + }); + selector_div.addEventListener('dblclick', function(e) { + if (e.target.tagName === 'OPTION') { + if (e.target.closest('select').id === field_id + '_to') { + SelectBox.move(field_id + '_to', field_id + '_from'); + } else { + SelectBox.move(field_id + '_from', field_id + '_to'); + } + SelectFilter.refresh_icons(field_id); + } + }); + from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); + SelectBox.select_all(field_id + '_to'); + }); + SelectBox.init(field_id + '_from'); + SelectBox.init(field_id + '_to'); + // Move selected from_box options to to_box + SelectBox.move(field_id + '_from', field_id + '_to'); + + // Initial icon refresh + SelectFilter.refresh_icons(field_id); + }, + any_selected: function(field) { + // Temporarily add the required attribute and check validity. + field.required = true; + const any_selected = field.checkValidity(); + field.required = false; + return any_selected; + }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, + refresh_icons: function(field_id) { + const from = document.getElementById(field_id + '_from'); + const to = document.getElementById(field_id + '_to'); + // Active if at least one item is selected + document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); + document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); + // Active if the corresponding box isn't empty + document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); + document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); + }, + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // don't submit form if user pressed Enter + if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; + event.preventDefault(); + } + }, + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }, + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; + // right arrow -- move across + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; + return; + } + // down arrow -- wrap around + if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; + } + // up arrow -- wrap around + if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; + } + } + }; + + window.addEventListener('load', function(e) { + document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { + const data = el.dataset; + SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); + }); + }); +} diff --git a/static/admin/js/actions.js b/static/admin/js/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..20a5c14353affa7d83a241f85c0b7ca0a434c58a --- /dev/null +++ b/static/admin/js/actions.js @@ -0,0 +1,201 @@ +/*global gettext, interpolate, ngettext*/ +'use strict'; +{ + function show(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.remove('hidden'); + }); + } + + function hide(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.add('hidden'); + }); + } + + function showQuestion(options) { + hide(options.acrossClears); + show(options.acrossQuestions); + hide(options.allContainer); + } + + function showClear(options) { + show(options.acrossClears); + hide(options.acrossQuestions); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + show(options.allContainer); + hide(options.counterContainer); + } + + function reset(options) { + hide(options.acrossClears); + hide(options.acrossQuestions); + hide(options.allContainer); + show(options.counterContainer); + } + + function clearAcross(options) { + reset(options); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 0; + }); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + } + + function checker(actionCheckboxes, options, checked) { + if (checked) { + showQuestion(options); + } else { + reset(options); + } + actionCheckboxes.forEach(function(el) { + el.checked = checked; + el.closest('tr').classList.toggle(options.selectedClass, checked); + }); + } + + function updateCounter(actionCheckboxes, options) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } + } + + const defaults = { + actionContainer: "div.actions", + counterContainer: "span.action-counter", + allContainer: "div.actions span.all", + acrossInput: "div.actions input.select-across", + acrossQuestions: "div.actions span.question", + acrossClears: "div.actions span.clear", + allToggleId: "action-toggle", + selectedClass: "selected" + }; + + window.Actions = function(actionCheckboxes, options) { + options = Object.assign({}, defaults, options); + let list_editable_changed = false; + let lastChecked = null; + let shiftPressed = false; + + document.addEventListener('keydown', (event) => { + shiftPressed = event.shiftKey; + }); + + document.addEventListener('keyup', (event) => { + shiftPressed = event.shiftKey; + }); + + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); + updateCounter(actionCheckboxes, options); + }); + }); + + function affectedCheckboxes(target, withModifier) { + const multiSelect = (lastChecked && withModifier && lastChecked !== target); + if (!multiSelect) { + return [target]; + } + const checkboxes = Array.from(actionCheckboxes); + const targetIndex = checkboxes.findIndex(el => el === target); + const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); + const startIndex = Math.min(targetIndex, lastCheckedIndex); + const endIndex = Math.max(targetIndex, lastCheckedIndex); + const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + return filtered; + }; + + Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } + } + }); + + const el = document.querySelector('#changelist-form input[name=_save]'); + // The button does not exist if no fields are editable. + if (el) { + el.addEventListener('click', function(event) { + if (document.querySelector('[name=action]').value) { + const text = list_editable_changed + ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") + : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); + if (!confirm(text)) { + event.preventDefault(); + } + } + }); + } + }; + + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + const actionsEls = document.querySelectorAll('tr input.action-select'); + if (actionsEls.length > 0) { + Actions(actionsEls); + } + }); +} diff --git a/static/admin/js/admin/DateTimeShortcuts.js b/static/admin/js/admin/DateTimeShortcuts.js new file mode 100644 index 0000000000000000000000000000000000000000..aa1cae9eeb451b0aefffeb7f5c6ea75753b6a190 --- /dev/null +++ b/static/admin/js/admin/DateTimeShortcuts.js @@ -0,0 +1,408 @@ +/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ +// Inserts shortcut buttons after all of the following: +// +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
    that gets toggled + calendarDivName2: 'calendarin', // name of
    that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
    that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of document.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); + warning.textContent = message; + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
    + //

    Choose a time

    + // + //

    Cancel

    + //
    + + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.style.position = 'absolute'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(today_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
    + //

    + // + // February 2003 + //

    + //
    + // + //
    + //
    + // Yesterday | Today | Tomorrow + //
    + //

    Cancel

    + //
    + const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.style.position = 'absolute'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/static/admin/js/admin/RelatedObjectLookups.js b/static/admin/js/admin/RelatedObjectLookups.js new file mode 100644 index 0000000000000000000000000000000000000000..afb6b66c2565d19e62b6393a23bd2bfadd0d8226 --- /dev/null +++ b/static/admin/js/admin/RelatedObjectLookups.js @@ -0,0 +1,238 @@ +/*global SelectBox, interpolate*/ +// Handles related-objects functionality: lookup link for raw_id_fields +// and Add Another links. +'use strict'; +{ + const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } + + function showAdminPopup(triggeringLink, name_regexp, add_popup) { + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); + const href = new URL(triggeringLink.href); + if (add_popup) { + href.searchParams.set('_popup', 1); + } + const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); + win.focus(); + return false; + } + + function showRelatedObjectLookupPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^lookup_/, true); + } + + function dismissRelatedLookupPopup(win, chosenId) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + chosenId; + } else { + document.getElementById(name).value = chosenId; + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function showRelatedObjectPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); + } + + function updateRelatedObjectLinks(triggeringLink) { + const $this = $(triggeringLink); + const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); + if (!siblings.length) { + return; + } + const value = $this.val(); + if (value) { + siblings.each(function() { + const elm = $(this); + elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); + }); + } else { + siblings.removeAttr('href'); + } + } + + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + + function dismissAddRelatedObjectPopup(win, newId, newRepr) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem) { + const elemName = elem.nodeName.toUpperCase(); + if (elemName === 'SELECT') { + elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); + } else if (elemName === 'INPUT') { + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + newId; + } else { + elem.value = newId; + } + } + // Trigger a change event to update related links if required. + $(elem).trigger('change'); + } else { + const toId = name + "_to"; + const o = new Option(newRepr, newId); + SelectBox.add_to_cache(toId, o); + SelectBox.redisplay(toId); + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { + const id = removePopupIndex(win.name.replace(/^edit_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + this.textContent = newRepr; + this.value = newId; + } + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); + selects.next().find('.select2-selection__rendered').each(function() { + // The element can have a clear button as a child. + // Use the lastChild to modify only the displayed value. + this.lastChild.textContent = newRepr; + this.title = newRepr; + }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissDeleteRelatedObjectPopup(win, objId) { + const id = removePopupIndex(win.name.replace(/^delete_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + $(this).remove(); + } + }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; + window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; + window.showRelatedObjectPopup = showRelatedObjectPopup; + window.updateRelatedObjectLinks = updateRelatedObjectLinks; + window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; + window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; + window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; + + // Kept for backward compatibility + window.showAddAnotherPopup = showRelatedObjectPopup; + window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + + $(document).ready(function() { + setPopupIndex(); + $("a[data-popup-opener]").on('click', function(event) { + event.preventDefault(); + opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); + }); + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { + e.preventDefault(); + if (this.href) { + const event = $.Event('django:show-related', {href: this.href}); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectPopup(this); + } + } + }); + $('body').on('change', '.related-widget-wrapper select', function(e) { + const event = $.Event('django:update-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + updateRelatedObjectLinks(this); + } + }); + $('.related-widget-wrapper select').trigger('change'); + $('body').on('click', '.related-lookup', function(e) { + e.preventDefault(); + const event = $.Event('django:lookup-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectLookupPopup(this); + } + }); + }); +} diff --git a/static/admin/js/autocomplete.js b/static/admin/js/autocomplete.js new file mode 100644 index 0000000000000000000000000000000000000000..d3daeab8909597077addecf34ab05800705f77c8 --- /dev/null +++ b/static/admin/js/autocomplete.js @@ -0,0 +1,33 @@ +'use strict'; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function() { + $.each(this, function(i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); + }); + return this; + }; + + $(function() { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); + }); + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); +} diff --git a/static/admin/js/calendar.js b/static/admin/js/calendar.js new file mode 100644 index 0000000000000000000000000000000000000000..a62d10a75980b7a73e79542b0da2b6f3c66ea829 --- /dev/null +++ b/static/admin/js/calendar.js @@ -0,0 +1,221 @@ +/*global gettext, pgettext, get_format, quickElement, removeChildren*/ +/* +calendar.js - Calendar functions by Adrian Holovaty +depends on core.js for utility functions like removeChildren or quickElement +*/ +'use strict'; +{ + // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions + const CalendarNamespace = { + monthsOfYear: [ + gettext('January'), + gettext('February'), + gettext('March'), + gettext('April'), + gettext('May'), + gettext('June'), + gettext('July'), + gettext('August'), + gettext('September'), + gettext('October'), + gettext('November'), + gettext('December') + ], + monthsOfYearAbbrev: [ + pgettext('abbrev. month January', 'Jan'), + pgettext('abbrev. month February', 'Feb'), + pgettext('abbrev. month March', 'Mar'), + pgettext('abbrev. month April', 'Apr'), + pgettext('abbrev. month May', 'May'), + pgettext('abbrev. month June', 'Jun'), + pgettext('abbrev. month July', 'Jul'), + pgettext('abbrev. month August', 'Aug'), + pgettext('abbrev. month September', 'Sep'), + pgettext('abbrev. month October', 'Oct'), + pgettext('abbrev. month November', 'Nov'), + pgettext('abbrev. month December', 'Dec') + ], + daysOfWeek: [ + pgettext('one letter Sunday', 'S'), + pgettext('one letter Monday', 'M'), + pgettext('one letter Tuesday', 'T'), + pgettext('one letter Wednesday', 'W'), + pgettext('one letter Thursday', 'T'), + pgettext('one letter Friday', 'F'), + pgettext('one letter Saturday', 'S') + ], + firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), + isLeapYear: function(year) { + return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); + }, + getDaysInMonth: function(month, year) { + let days; + if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { + days = 31; + } + else if (month === 4 || month === 6 || month === 9 || month === 11) { + days = 30; + } + else if (month === 2 && CalendarNamespace.isLeapYear(year)) { + days = 29; + } + else { + days = 28; + } + return days; + }, + draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + const todayYear = today.getFullYear(); + let todayClass = ''; + + // Use UTC functions here because the date field does not contain time + // and using the UTC function variants prevent the local time offset + // from altering the date, specifically the day field. For example: + // + // ``` + // var x = new Date('2013-10-02'); + // var day = x.getDate(); + // ``` + // + // The day variable above will be 1 instead of 2 in, say, US Pacific time + // zone. + let isSelectedMonth = false; + if (typeof selected !== 'undefined') { + isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); + } + + month = parseInt(month); + year = parseInt(year); + const calDiv = document.getElementById(div_id); + removeChildren(calDiv); + const calTable = document.createElement('table'); + quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); + const tableBody = quickElement('tbody', calTable); + + // Draw days-of-week header + let tableRow = quickElement('tr', tableBody); + for (let i = 0; i < 7; i++) { + quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); + } + + const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); + const days = CalendarNamespace.getDaysInMonth(month, year); + + let nonDayCell; + + // Draw blanks before first of month + tableRow = quickElement('tr', tableBody); + for (let i = 0; i < startingPos; i++) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + function calendarMonth(y, m) { + function onClick(e) { + e.preventDefault(); + callback(y, m, this.textContent); + } + return onClick; + } + + // Draw days of month + let currentDay = 1; + for (let i = startingPos; currentDay <= days; i++) { + if (i % 7 === 0 && currentDay !== 1) { + tableRow = quickElement('tr', tableBody); + } + if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { + todayClass = 'today'; + } else { + todayClass = ''; + } + + // use UTC function; see above for explanation. + if (isSelectedMonth && currentDay === selected.getUTCDate()) { + if (todayClass !== '') { + todayClass += " "; + } + todayClass += "selected"; + } + + const cell = quickElement('td', tableRow, '', 'class', todayClass); + const link = quickElement('a', cell, currentDay, 'href', '#'); + link.addEventListener('click', calendarMonth(year, month)); + currentDay++; + } + + // Draw blanks after end of month (optional, but makes for valid code) + while (tableRow.childNodes.length < 7) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + calDiv.appendChild(calTable); + } + }; + + // Calendar -- A calendar instance + function Calendar(div_id, callback, selected) { + // div_id (string) is the ID of the element in which the calendar will + // be displayed + // callback (string) is the name of a JavaScript function that will be + // called with the parameters (year, month, day) when a day in the + // calendar is clicked + this.div_id = div_id; + this.callback = callback; + this.today = new Date(); + this.currentMonth = this.today.getMonth() + 1; + this.currentYear = this.today.getFullYear(); + if (typeof selected !== 'undefined') { + this.selected = selected; + } + } + Calendar.prototype = { + drawCurrent: function() { + CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); + }, + drawDate: function(month, year, selected) { + this.currentMonth = month; + this.currentYear = year; + + if(selected) { + this.selected = selected; + } + + this.drawCurrent(); + }, + drawPreviousMonth: function() { + if (this.currentMonth === 1) { + this.currentMonth = 12; + this.currentYear--; + } + else { + this.currentMonth--; + } + this.drawCurrent(); + }, + drawNextMonth: function() { + if (this.currentMonth === 12) { + this.currentMonth = 1; + this.currentYear++; + } + else { + this.currentMonth++; + } + this.drawCurrent(); + }, + drawPreviousYear: function() { + this.currentYear--; + this.drawCurrent(); + }, + drawNextYear: function() { + this.currentYear++; + this.drawCurrent(); + } + }; + window.Calendar = Calendar; + window.CalendarNamespace = CalendarNamespace; +} diff --git a/static/admin/js/cancel.js b/static/admin/js/cancel.js new file mode 100644 index 0000000000000000000000000000000000000000..3069c6f27bfdec0b246d21311f50951e5ed6c356 --- /dev/null +++ b/static/admin/js/cancel.js @@ -0,0 +1,29 @@ +'use strict'; +{ + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + function handleClick(event) { + event.preventDefault(); + const params = new URLSearchParams(window.location.search); + if (params.has('_popup')) { + window.close(); // Close the popup. + } else { + window.history.back(); // Otherwise, go back. + } + } + + document.querySelectorAll('.cancel-link').forEach(function(el) { + el.addEventListener('click', handleClick); + }); + }); +} diff --git a/static/admin/js/change_form.js b/static/admin/js/change_form.js new file mode 100644 index 0000000000000000000000000000000000000000..96a4c62ef4c353109f22c5d4c7bf7827fe74597b --- /dev/null +++ b/static/admin/js/change_form.js @@ -0,0 +1,16 @@ +'use strict'; +{ + const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; + const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; + if (modelName) { + const form = document.getElementById(modelName + '_form'); + for (const element of form.elements) { + // HTMLElement.offsetParent returns null when the element is not + // rendered. + if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { + element.focus(); + break; + } + } + } +} diff --git a/static/admin/js/collapse.js b/static/admin/js/collapse.js new file mode 100644 index 0000000000000000000000000000000000000000..c6c7b0f68a2d96cbbcbe783498dd9b2bb5e7e064 --- /dev/null +++ b/static/admin/js/collapse.js @@ -0,0 +1,43 @@ +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const h2 = elem.querySelector('h2'); + const link = document.createElement('a'); + link.id = 'fieldsetcollapser' + i; + link.className = 'collapse-toggle'; + link.href = '#'; + link.textContent = gettext('Show'); + h2.appendChild(document.createTextNode(' (')); + h2.appendChild(link); + h2.appendChild(document.createTextNode(')')); + } + } + // Add toggle to hide/show anchor tag + const toggleFunc = function(ev) { + if (ev.target.matches('.collapse-toggle')) { + ev.preventDefault(); + ev.stopPropagation(); + const fieldset = ev.target.closest('fieldset'); + if (fieldset.classList.contains('collapsed')) { + // Show + ev.target.textContent = gettext('Hide'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + ev.target.textContent = gettext('Show'); + fieldset.classList.add('collapsed'); + } + } + }; + document.querySelectorAll('fieldset.module').forEach(function(el) { + el.addEventListener('click', toggleFunc); + }); + }); +} diff --git a/static/admin/js/core.js b/static/admin/js/core.js new file mode 100644 index 0000000000000000000000000000000000000000..0344a13f42af20cc292f40ccc2e23a5d8e1115dc --- /dev/null +++ b/static/admin/js/core.js @@ -0,0 +1,170 @@ +// Core JavaScript helper functions +'use strict'; + +// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); +function quickElement() { + const obj = document.createElement(arguments[0]); + if (arguments[2]) { + const textNode = document.createTextNode(arguments[2]); + obj.appendChild(textNode); + } + const len = arguments.length; + for (let i = 3; i < len; i += 2) { + obj.setAttribute(arguments[i], arguments[i + 1]); + } + arguments[1].appendChild(obj); + return obj; +} + +// "a" is reference to an object +function removeChildren(a) { + while (a.hasChildNodes()) { + a.removeChild(a.lastChild); + } +} + +// ---------------------------------------------------------------------------- +// Find-position functions by PPK +// See https://www.quirksmode.org/js/findpos.html +// ---------------------------------------------------------------------------- +function findPosX(obj) { + let curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - obj.scrollLeft; + obj = obj.offsetParent; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; +} + +function findPosY(obj) { + let curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - obj.scrollTop; + obj = obj.offsetParent; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; +} + +//----------------------------------------------------------------------------- +// Date object extensions +// ---------------------------------------------------------------------------- +{ + Date.prototype.getTwelveHours = function() { + return this.getHours() % 12 || 12; + }; + + Date.prototype.getTwoDigitMonth = function() { + return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); + }; + + Date.prototype.getTwoDigitDate = function() { + return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); + }; + + Date.prototype.getTwoDigitTwelveHour = function() { + return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); + }; + + Date.prototype.getTwoDigitHour = function() { + return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); + }; + + Date.prototype.getTwoDigitMinute = function() { + return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); + }; + + Date.prototype.getTwoDigitSecond = function() { + return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); + }; + + Date.prototype.getAbbrevMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; + }; + + Date.prototype.getFullMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYear[this.getMonth()]; + }; + + Date.prototype.strftime = function(format) { + const fields = { + b: this.getAbbrevMonthName(), + B: this.getFullMonthName(), + c: this.toString(), + d: this.getTwoDigitDate(), + H: this.getTwoDigitHour(), + I: this.getTwoDigitTwelveHour(), + m: this.getTwoDigitMonth(), + M: this.getTwoDigitMinute(), + p: (this.getHours() >= 12) ? 'PM' : 'AM', + S: this.getTwoDigitSecond(), + w: '0' + this.getDay(), + x: this.toLocaleDateString(), + X: this.toLocaleTimeString(), + y: ('' + this.getFullYear()).substr(2, 4), + Y: '' + this.getFullYear(), + '%': '%' + }; + let result = '', i = 0; + while (i < format.length) { + if (format.charAt(i) === '%') { + result += fields[format.charAt(i + 1)]; + ++i; + } + else { + result += format.charAt(i); + } + ++i; + } + return result; + }; + + // ---------------------------------------------------------------------------- + // String object extensions + // ---------------------------------------------------------------------------- + String.prototype.strptime = function(format) { + const split_format = format.split(/[.\-/]/); + const date = this.split(/[.\-/]/); + let i = 0; + let day, month, year; + while (i < split_format.length) { + switch (split_format[i]) { + case "%d": + day = date[i]; + break; + case "%m": + month = date[i] - 1; + break; + case "%Y": + year = date[i]; + break; + case "%y": + // A %y value in the range of [00, 68] is in the current + // century, while [69, 99] is in the previous century, + // according to the Open Group Specification. + if (parseInt(date[i], 10) >= 69) { + year = date[i]; + } else { + year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; + } + break; + } + ++i; + } + // Create Date object from UTC since the parsed value is supposed to be + // in UTC, not local time. Also, the calendar uses UTC functions for + // date extraction. + return new Date(Date.UTC(year, month, day)); + }; +} diff --git a/static/admin/js/filters.js b/static/admin/js/filters.js new file mode 100644 index 0000000000000000000000000000000000000000..f5536ebc2d3b10ded6750bd5d9eba59566733ea7 --- /dev/null +++ b/static/admin/js/filters.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/static/admin/js/inlines.js b/static/admin/js/inlines.js new file mode 100644 index 0000000000000000000000000000000000000000..e9a1dfe12299e9d7e436e832c0c393e05b311c73 --- /dev/null +++ b/static/admin/js/inlines.js @@ -0,0 +1,359 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: https://opensource.org/licenses/bsd-license.php + */ +'use strict'; +{ + const $ = django.jQuery; + $.fn.formset = function(opts) { + const options = $.extend({}, $.fn.formset.defaults, opts); + const $this = $(this); + const $parent = $this.parent(); + const updateElementIndex = function(el, prefix, ndx) { + const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + const replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + let nextIndex = parseInt(totalForms.val(), 10); + const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); + let addButton; + + /** + * The "Add another MyModel" button below the inline forms. + */ + const addInlineAddButton = function() { + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + const numCols = $this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.on('click', addInlineClickHandler); + }; + + const addInlineClickHandler = function(e) { + e.preventDefault(); + const template = $("#" + options.prefix + "-empty"); + const row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + addInlineDeleteButton(row); + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited. + row.insertBefore($(template)); + // Update number of total forms. + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide the add button if there's a limit and it's been reached. + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // Show the remove buttons if there are more than min_num. + toggleDeleteButtonVisibility(row.closest('.inline-group')); + + // Pass the new form to the post-add callback, if provided. + if (options.added) { + options.added(row); + } + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); + }; + + /** + * The "X" button that is part of every unsaved inline. + * (When saved, it is replaced with a "Delete" checkbox.) + */ + const addInlineDeleteButton = function(row) { + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
  • after the last list item: + row.append('
  • ' + options.deleteText + "
  • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + // Add delete handler for each row. + row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); + }; + + const inlineDeleteHandler = function(e1) { + e1.preventDefault(); + const deleteButton = $(e1.target); + const row = deleteButton.closest('.' + options.formCssClass); + const inlineGroup = row.closest('.inline-group'); + // Remove the parent form containing this button, + // and also remove the relevant row with non-field errors: + const prevRow = row.prev(); + if (prevRow.length && prevRow.hasClass('row-form-errors')) { + prevRow.remove(); + } + row.remove(); + nextIndex -= 1; + // Pass the deleted form to the post-delete callback, if provided. + if (options.removed) { + options.removed(row); + } + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); + // Update the TOTAL_FORMS form count. + const forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once below maximum number. + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Hide the remove buttons if at min_num. + toggleDeleteButtonVisibility(inlineGroup); + // Also, update names and ids for all remaining form controls so + // they remain in sequence: + let i, formCount; + const updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }; + + const toggleDeleteButtonVisibility = function(inlineGroup) { + if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { + inlineGroup.find('.inline-deletelink').hide(); + } else { + inlineGroup.find('.inline-deletelink').show(); + } + }; + + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + + // Create the delete buttons for all unsaved inlines: + $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { + addInlineDeleteButton($(this)); + }); + toggleDeleteButtonVisibility($this); + + // Create the add button, initially hidden. + addButton = options.addButton; + addInlineAddButton(); + + // Show the add button if allowed to add more items. + // Note that max_num = None translates to a blank string. + const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + if ($this.length && showAddButton) { + addButton.parent().show(); + } else { + addButton.parent().hide(); + } + + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(selector, options) { + const $rows = $(this); + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $('.selectfilterstacked').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(selector, options) { + const $rows = $(this); + const updateInlineLabel = function(row) { + $(selector).find(".inline_label").each(function(i) { + const count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $(".selectfilterstacked").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + const data = $(this).data(), + inlineOptions = data.inlineFormset; + let selector; + switch(data.inlineType) { + case "stacked": + selector = inlineOptions.name + "-group .inline-related"; + $(selector).stackedFormset(selector, inlineOptions.options); + break; + case "tabular": + selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; + $(selector).tabularFormset(selector, inlineOptions.options); + break; + } + }); + }); +} diff --git a/static/admin/js/jquery.init.js b/static/admin/js/jquery.init.js new file mode 100644 index 0000000000000000000000000000000000000000..f40b27f47da411062930b3758fc7b242e932f91e --- /dev/null +++ b/static/admin/js/jquery.init.js @@ -0,0 +1,8 @@ +/*global jQuery:false*/ +'use strict'; +/* Puts the included jQuery into our own namespace using noConflict and passing + * it 'true'. This ensures that the included jQuery doesn't pollute the global + * namespace (i.e. this preserves pre-existing values for both window.$ and + * window.jQuery). + */ +window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/static/admin/js/nav_sidebar.js b/static/admin/js/nav_sidebar.js new file mode 100644 index 0000000000000000000000000000000000000000..7e735db15cf33805b1ab498b4476f218d4f690a5 --- /dev/null +++ b/static/admin/js/nav_sidebar.js @@ -0,0 +1,79 @@ +'use strict'; +{ + const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); + if (toggleNavSidebar !== null) { + const navSidebar = document.getElementById('nav-sidebar'); + const main = document.getElementById('main'); + let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); + if (navSidebarIsOpen === null) { + navSidebarIsOpen = 'true'; + } + main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + + toggleNavSidebar.addEventListener('click', function() { + if (navSidebarIsOpen === 'true') { + navSidebarIsOpen = 'false'; + } else { + navSidebarIsOpen = 'true'; + } + localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); + main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); + }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } + } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); +} diff --git a/static/admin/js/popup_response.js b/static/admin/js/popup_response.js new file mode 100644 index 0000000000000000000000000000000000000000..2b1d3dd31d7acba2456e79bd3430c7e075883676 --- /dev/null +++ b/static/admin/js/popup_response.js @@ -0,0 +1,16 @@ +/*global opener */ +'use strict'; +{ + const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); + switch(initData.action) { + case 'change': + opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); + break; + case 'delete': + opener.dismissDeleteRelatedObjectPopup(window, initData.value); + break; + default: + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + break; + } +} diff --git a/static/admin/js/prepopulate.js b/static/admin/js/prepopulate.js new file mode 100644 index 0000000000000000000000000000000000000000..89e95ab44dc74c5be08339a937908a73c15c86ab --- /dev/null +++ b/static/admin/js/prepopulate.js @@ -0,0 +1,43 @@ +/*global URLify*/ +'use strict'; +{ + const $ = django.jQuery; + $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { + /* + Depends on urlify.js + Populates a selected field with the values of the dependent fields, + URLifies and shortens the string. + dependencies - array of dependent fields ids + maxLength - maximum length of the URLify'd string + allowUnicode - Unicode support of the URLify'd string + */ + return this.each(function() { + const prepopulatedField = $(this); + + const populate = function() { + // Bail if the field's value has been changed by the user + if (prepopulatedField.data('_changed')) { + return; + } + + const values = []; + $.each(dependencies, function(i, field) { + field = $(field); + if (field.val().length > 0) { + values.push(field.val()); + } + }); + prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); + }; + + prepopulatedField.data('_changed', false); + prepopulatedField.on('change', function() { + prepopulatedField.data('_changed', true); + }); + + if (!prepopulatedField.val()) { + $(dependencies.join(',')).on('keyup change focus', populate); + } + }); + }; +} diff --git a/static/admin/js/prepopulate_init.js b/static/admin/js/prepopulate_init.js new file mode 100644 index 0000000000000000000000000000000000000000..a58841f004129659c58f6cfa9c05174e0e63c63b --- /dev/null +++ b/static/admin/js/prepopulate_init.js @@ -0,0 +1,15 @@ +'use strict'; +{ + const $ = django.jQuery; + const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); + $.each(fields, function(index, field) { + $( + '.empty-form .form-row .field-' + field.name + + ', .empty-form.form-row .field-' + field.name + + ', .empty-form .form-row.field-' + field.name + ).addClass('prepopulated_field'); + $(field.id).data('dependency_list', field.dependency_list).prepopulate( + field.dependency_ids, field.maxLength, field.allowUnicode + ); + }); +} diff --git a/static/admin/js/theme.js b/static/admin/js/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..794cd15f701aed5214280a6404e9beb21d751a6c --- /dev/null +++ b/static/admin/js/theme.js @@ -0,0 +1,56 @@ +'use strict'; +{ + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); + } + + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/static/admin/js/urlify.js b/static/admin/js/urlify.js new file mode 100644 index 0000000000000000000000000000000000000000..9fc04094964786db564c511b439244ee7015ccea --- /dev/null +++ b/static/admin/js/urlify.js @@ -0,0 +1,169 @@ +/*global XRegExp*/ +'use strict'; +{ + const LATIN_MAP = { + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', + 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', + 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', + 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', + 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', + 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', + 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' + }; + const LATIN_SYMBOLS_MAP = { + '©': '(c)' + }; + const GREEK_MAP = { + 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', + 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', + 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', + 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', + 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', + 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', + 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', + 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', + 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', + 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' + }; + const TURKISH_MAP = { + 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', + 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' + }; + const ROMANIAN_MAP = { + 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', + 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' + }; + const RUSSIAN_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', + 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', + 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + }; + const UKRAINIAN_MAP = { + 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', + 'ї': 'yi', 'ґ': 'g' + }; + const CZECH_MAP = { + 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', + 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', + 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' + }; + const SLOVAK_MAP = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', + 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', + 'ú': 'u', 'ý': 'y', 'ž': 'z', + 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', + 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', + 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' + }; + const POLISH_MAP = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', + 'ź': 'z', 'ż': 'z', + 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', + 'Ź': 'Z', 'Ż': 'Z' + }; + const LATVIAN_MAP = { + 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', + 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', + 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', + 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' + }; + const ARABIC_MAP = { + 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', + 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', + 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', + 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' + }; + const LITHUANIAN_MAP = { + 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', + 'ū': 'u', 'ž': 'z', + 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', + 'Ū': 'U', 'Ž': 'Z' + }; + const SERBIAN_MAP = { + 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', + 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', + 'Џ': 'Dz', 'Đ': 'Dj' + }; + const AZERBAIJANI_MAP = { + 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', + 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' + }; + const GEORGIAN_MAP = { + 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', + 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', + 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', + 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', + 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' + }; + + const ALL_DOWNCODE_MAPS = [ + LATIN_MAP, + LATIN_SYMBOLS_MAP, + GREEK_MAP, + TURKISH_MAP, + ROMANIAN_MAP, + RUSSIAN_MAP, + UKRAINIAN_MAP, + CZECH_MAP, + SLOVAK_MAP, + POLISH_MAP, + LATVIAN_MAP, + ARABIC_MAP, + LITHUANIAN_MAP, + SERBIAN_MAP, + AZERBAIJANI_MAP, + GEORGIAN_MAP + ]; + + const Downcoder = { + 'Initialize': function() { + if (Downcoder.map) { // already made + return; + } + Downcoder.map = {}; + for (const lookup of ALL_DOWNCODE_MAPS) { + Object.assign(Downcoder.map, lookup); + } + Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); + } + }; + + function downcode(slug) { + Downcoder.Initialize(); + return slug.replace(Downcoder.regex, function(m) { + return Downcoder.map[m]; + }); + } + + + function URLify(s, num_chars, allowUnicode) { + // changes, e.g., "Petty theft" to "petty-theft" + if (!allowUnicode) { + s = downcode(s); + } + s = s.toLowerCase(); // convert to lowercase + // if downcode doesn't hit, the char will be stripped here + if (allowUnicode) { + // Keep Unicode letters including both lowercase and uppercase + // characters, whitespace, and dash; remove other characters. + s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); + } else { + s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars + } + s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces + s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens + s = s.substring(0, num_chars); // trim to first num_chars chars + return s.replace(/-+$/g, ''); // trim any trailing hyphens + } + window.URLify = URLify; +} diff --git a/static/admin/js/vendor/jquery/LICENSE.txt b/static/admin/js/vendor/jquery/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..f642c3f7a77a5833bc3dfee643ad8ca6387b23e7 --- /dev/null +++ b/static/admin/js/vendor/jquery/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +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. diff --git a/static/admin/js/vendor/jquery/jquery.js b/static/admin/js/vendor/jquery/jquery.js new file mode 100644 index 0000000000000000000000000000000000000000..7f35c11bdf388b4516df3bc03ec3488e8b2654b2 --- /dev/null +++ b/static/admin/js/vendor/jquery/jquery.js @@ -0,0 +1,10965 @@ +/*! + * jQuery JavaScript Library v3.6.4 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2023-03-08T15:28Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket trac-14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var flat = arr.flat ? function( array ) { + return arr.flat.call( array ); +} : function( array ) { + return arr.concat.apply( [], array ); +}; + + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + +var document = window.document; + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.6.4", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + even: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return ( i + 1 ) % 2; + } ) ); + }, + + odd: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return i % 2; + } ) ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a provided context; falls back to the global one + // if not specified. + globalEval: function( code, options, doc ) { + DOMEval( code, { nonce: options && options.nonce }, doc ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return flat( ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.10 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2023-02-14 + */ +( function( window ) { +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ( {} ).hasOwnProperty, + arr = [], + pop = arr.pop, + pushNative = arr.push, + push = arr.push, + slice = arr.slice, + + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[ i ] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + + "ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + + "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + + // "Attribute values must be CSS identifiers [capture 5] + // or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + + whitespace + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), + funescape = function( escape, nonHex ) { + var high = "0x" + escape.slice( 1 ) - 0x10000; + + return nonHex ? + + // Strip the backslash prefix from a non-hex escape sequence + nonHex : + + // Replace a hexadecimal escape sequence with the encoded Unicode code point + // Support: IE <=11+ + // For values outside the Basic Multilingual Plane (BMP), manually construct a + // surrogate pair + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + ( arr = slice.call( preferredDoc.childNodes ) ), + preferredDoc.childNodes + ); + + // Support: Android<4.0 + // Detect silently failing push.apply + // eslint-disable-next-line no-unused-expressions + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + pushNative.apply( target, slice.call( els ) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + + // Can't trust NodeList.length + while ( ( target[ j++ ] = els[ i++ ] ) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + setDocument( context ); + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { + + // ID selector + if ( ( m = match[ 1 ] ) ) { + + // Document context + if ( nodeType === 9 ) { + if ( ( elem = context.getElementById( m ) ) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && ( elem = newContext.getElementById( m ) ) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[ 2 ] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && + + // Support: IE 8 only + // Exclude object elements + ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // The technique has to be used as well when a leading combinator is used + // as such selectors are not recognized by querySelectorAll. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && + ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + + // We can use :scope instead of the ID hack if the browser + // supports it & if we're not changing the context. + if ( newContext !== context || !support.scope ) { + + // Capture the context ID, setting it first if necessary + if ( ( nid = context.getAttribute( "id" ) ) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", ( nid = expando ) ); + } + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + + toSelector( groups[ i ] ); + } + newSelector = groups.join( "," ); + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return ( cache[ key + " " ] = value ); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement( "fieldset" ); + + try { + return !!fn( el ); + } catch ( e ) { + return false; + } finally { + + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split( "|" ), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[ i ] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( ( cur = cur.nextSibling ) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return ( name === "input" || name === "button" ) && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction( function( argument ) { + argument = +argument; + return markFunction( function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ ( j = matchIndexes[ i ] ) ] ) { + seed[ j ] = !( matches[ j ] = seed[ j ] ); + } + } + } ); + } ); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9 - 11+, Edge 12 - 18+ + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( preferredDoc != document && + ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, + // Safari 4 - 5 only, Opera <=11.6 - 12.x only + // IE/Edge & older browsers don't support the :scope pseudo-class. + // Support: Safari 6.0 only + // Safari 6.0 supports :scope but it's an alias of :root there. + support.scope = assert( function( el ) { + docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); + return typeof el.querySelectorAll !== "undefined" && + !el.querySelectorAll( ":scope fieldset div" ).length; + } ); + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Make sure the the `:has()` argument is parsed unforgivingly. + // We include `*` in the test to detect buggy implementations that are + // _selectively_ forgiving (specifically when the list includes at least + // one valid selector). + // Note that we treat complete lack of support for `:has()` as if it were + // spec-compliant support, which is fine because use of `:has()` in such + // environments will fail in the qSA path and fall back to jQuery traversal + // anyway. + support.cssHas = assert( function() { + try { + document.querySelector( ":has(*,:jqfake)" ); + return false; + } catch ( e ) { + return true; + } + } ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert( function( el ) { + el.className = "i"; + return !el.getAttribute( "className" ); + } ); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert( function( el ) { + el.appendChild( document.createComment( "" ) ); + return !el.getElementsByTagName( "*" ).length; + } ); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert( function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + } ); + + // ID filter and find + if ( support.getById ) { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute( "id" ) === attrId; + }; + }; + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode( "id" ); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( ( elem = elems[ i++ ] ) ) { + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find[ "TAG" ] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( ( elem = results[ i++ ] ) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert( function( el ) { + + var input; + + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll( "[selected]" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push( "~=" ); + } + + // Support: IE 11+, Edge 15 - 18+ + // IE 11/Edge don't find elements on a `[name='']` query in some cases. + // Adding a temporary attribute to the document before the selection works + // around the issue. + // Interestingly, IE 10 & older don't seem to have the issue. + input = document.createElement( "input" ); + input.setAttribute( "name", "" ); + el.appendChild( input ); + if ( !el.querySelectorAll( "[name='']" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + + whitespace + "*(?:''|\"\")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll( ":checked" ).length ) { + rbuggyQSA.push( ":checked" ); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push( ".#.+[+~]" ); + } + + // Support: Firefox <=3.6 - 5 only + // Old Firefox doesn't throw on a badly-escaped identifier. + el.querySelectorAll( "\\\f" ); + rbuggyQSA.push( "[\\r\\n\\f]" ); + } ); + + assert( function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement( "input" ); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll( "[name=d]" ).length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: Opera 10 - 11 only + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll( "*,:x" ); + rbuggyQSA.push( ",.*:" ); + } ); + } + + if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector ) ) ) ) { + + assert( function( el ) { + + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + } ); + } + + if ( !support.cssHas ) { + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Our regular `try-catch` mechanism fails to detect natively-unsupported + // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) + // in browsers that parse the `:has()` argument as a forgiving selector list. + // https://drafts.csswg.org/selectors/#relational now requires the argument + // to be parsed unforgivingly, but browsers have not yet fully adjusted. + rbuggyQSA.push( ":has" ); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within `