diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index ed9d5e3..4464beb 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -3,6 +3,11 @@ name: Build and Deploy to Kubernetes on: push: branches: [ main ] + paths-ignore: + - 'k8s/**' + pull_request: + paths-ignore: + - 'k8s/**' env: REGISTRY: ghcr.io @@ -10,18 +15,43 @@ env: K8S_NAMESPACE: apps jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + python-version: "3.12" + enable-cache: true + + - name: Install dependencies (including dev) + run: uv sync --dev + + - name: Run tests + env: + DJANGO_SETTINGS_MODULE: core.settings + FILE_UPLOADS_FOLDER: /tmp/test-uploads + run: uv run pytest tests/ -v --tb=short + build-and-deploy: runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' permissions: - contents: read + contents: write packages: write steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Log in to the Container registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -29,13 +59,17 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Set build timestamp + id: build_time + run: echo "value=$(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT + # Build and push main application image - name: Build and push Docker image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile @@ -45,14 +79,16 @@ jobs: ghcr.io/wahyd4/links:1.0.${{ github.run_number }} labels: |- ${{ steps.meta.outputs.labels }} + build-args: |- + BUILD_TIME=${{ steps.build_time.outputs.value }} + BUILD_VERSION=1.0.${{ github.run_number }} - - name: Deploy to Kubernetes - uses: wahyd4/kubectl-helm-action@master - env: - KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} - with: - args: |- - # Update both deployments with new image tags - sed -i 's|image: .*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:1.0.${{ github.run_number }}|' k8s/manifest.yaml - # Apply Kubernetes manifests - kubectl --insecure-skip-tls-verify apply -n ${{ env.K8S_NAMESPACE }} -f k8s/manifest.yaml + - name: Commit updated manifest + run: | + # Render template → manifest with the actual image tag + sed 's|__IMAGE_TAG__|1.0.${{ github.run_number }}|g' k8s/manifest.template.yaml > k8s/manifest.yaml + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add k8s/manifest.yaml + git commit -m "chore: update manifest image to 1.0.${{ github.run_number }} [skip ci]" + git push diff --git a/.gitignore b/.gitignore index ee7dc05..73cfdab 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,6 @@ DerivedData/ # Swift Package Manager .swiftpm/ .build/ +data/db.sqlite3 + +.playwright-mcp/ diff --git a/Dockerfile b/Dockerfile index 5971848..e482a51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,9 @@ RUN uv sync --no-dev --frozen --no-install-project COPY manage.py ./ COPY core/ ./core/ COPY links/ ./links/ +COPY netscan/ ./netscan/ +COPY nginxmon/ ./nginxmon/ +COPY routermon/ ./routermon/ COPY new_theme/ ./new_theme/ COPY templates/ ./templates/ COPY locale/ ./locale/ @@ -72,6 +75,10 @@ RUN rm -rf /tmp/.cache/uv ~/.npm ~/.cache \ # Production stage - use Python 3.12 slim FROM python:3.12-slim AS production +# Build-time metadata +ARG BUILD_TIME="" +ARG BUILD_VERSION="" + # Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 @@ -80,6 +87,8 @@ ENV VIRTUAL_ENV=/app/.venv ENV PATH="/app/.venv/bin:$PATH" ENV DEBIAN_FRONTEND=noninteractive ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright +ENV BUILD_TIME=${BUILD_TIME} +ENV BUILD_VERSION=${BUILD_VERSION} # Set work directory WORKDIR /app @@ -132,8 +141,12 @@ COPY --from=builder --chown=appuser:appuser /app/locale /app/locale COPY --chown=appuser:appuser manage.py ./ COPY --chown=appuser:appuser core/ ./core/ COPY --chown=appuser:appuser links/ ./links/ +COPY --chown=appuser:appuser netscan/ ./netscan/ +COPY --chown=appuser:appuser nginxmon/ ./nginxmon/ +COPY --chown=appuser:appuser routermon/ ./routermon/ COPY --chown=appuser:appuser new_theme/ ./new_theme/ COPY --chown=appuser:appuser templates/ ./templates/ +COPY --chown=appuser:appuser static/ ./static/ COPY --chown=appuser:appuser qdrant_sync.py ./ # Final cleanup diff --git a/NETSCAN_PLAN.md b/NETSCAN_PLAN.md new file mode 100644 index 0000000..bfe2219 --- /dev/null +++ b/NETSCAN_PLAN.md @@ -0,0 +1,429 @@ +# NetScan — Home Network Security Scanner + +A new Django sub-app (`netscan`) added to the Links project that runs configurable, scheduled security checks against your home network, stores results in SQLite, sends Telegram alerts for critical findings, and surfaces everything through a Tailwind UI inside the existing mini-apps section. + +--- + +## Context & Constraints + +- **Codebase**: Django + APScheduler + Tailwind + SQLite (PostgreSQL in prod via env var) +- **Deployment**: Same Docker image as the Links app, deployed in K3s at 192.168.1.2 (server-3) +- **Why in-cluster**: The scanner must run **inside the LAN** to probe 192.168.1.x addresses (router, cameras). Running on the cluster node satisfies this automatically. +- **No new dependencies except `requests`** (already present). All checks use stdlib `socket`, `ssl`, `subprocess`, `struct`. +- **No new JS framework** — pure Django templates + Tailwind, matching the rest of the app. + +--- + +## Database Models (`netscan/models.py`) + +### `ScanProfile` +| Field | Type | Notes | +|---|---|---| +| `name` | CharField | Human label e.g. "Home Network" | +| `enabled` | BooleanField | Controls scheduler | +| `schedule_interval` | IntegerField | 1 / 3 / 7 / 30 days (choices) | +| `gateway_ip` | GenericIPAddressField | e.g. 192.168.1.1 | +| `public_ip` | GenericIPAddressField | e.g. 14.137.198.99 | +| `network_cidr` | CharField | e.g. 192.168.1.0/24 | +| `auth_provider_host` | CharField | e.g. pass.junv.cc (for ingress check) | +| `domains` | JSONField | List of public hostnames to check | +| `cameras` | JSONField | List of camera IPs to probe | +| `telegram_bot_token` | CharField | blank/null; stored encrypted in env preferred | +| `telegram_chat_id` | CharField | blank/null | +| `notify_on_severity` | CharField | "warning" or "critical" (default "critical") | +| `last_run_at` | DateTimeField | null | +| `created_at` | DateTimeField | auto | + +### `ScanRun` +| Field | Type | Notes | +|---|---|---| +| `profile` | FK → ScanProfile | cascade delete | +| `started_at` | DateTimeField | | +| `finished_at` | DateTimeField | null | +| `status` | CharField | pending / running / success / failed | +| `summary` | JSONField | `{ok:N, info:N, warning:N, critical:N}` | +| `triggered_by` | CharField | "scheduler" or "manual" | + +### `ScanFinding` +| Field | Type | Notes | +|---|---|---| +| `run` | FK → ScanRun | cascade delete | +| `check_name` | CharField | e.g. "router_ports", "tls_expiry" | +| `severity` | CharField | ok / info / warning / critical | +| `title` | CharField | Short human summary | +| `detail` | TextField | Full explanation | +| `raw` | JSONField | Raw probe output for debugging | + +--- + +## Check Modules (`netscan/checks/`) + +Each module exposes a single function `run(profile) -> list[Finding]`. All use only stdlib + `requests`. + +### `checks/base.py` +```python +@dataclass +class Finding: + check_name: str + severity: str # ok | info | warning | critical + title: str + detail: str + raw: dict +``` + +### `checks/router.py` — Gateway security +- TCP connect-probe gateway IP on ports: 22, 23, 53, 80, 139, 443, 445, 8080, 8443 +- Fetch HTTP headers from port 80 to identify plain-HTTP admin (`Server: httpd`) +- Flag: SMB open (139/445) → **warning** +- Flag: SSH closed → **ok**; SSH open from WAN → **warning** +- Flag: plain-HTTP admin (no HTTPS on 443) → **warning** +- Flag: unknown port 8080 open → **info** + +### `checks/dns.py` — Open resolver (WAN DNS exposure) +- Send raw DNS query for `google.com A` to `profile.public_ip:53` over UDP using `socket` + `struct` +- Parse the response: if `NOERROR` + answer section returned → recursion available +- Flag: open recursive resolver → **critical** +- Note in detail: "Verify from off-LAN; NAT hairpin may cause false positive" + +### `checks/ingress.py` — Public domain auth verification +- For each domain in `profile.domains`: + - `requests.get(f"https://{domain}/", allow_redirects=True, timeout=8)` + - Check: does the redirect chain pass through `profile.auth_provider_host`? + - Check: does the final URL (after all redirects) contain the auth provider host? + - If final URL is the app itself (not the auth host) → **critical** (auth bypassed) + - If redirect goes through auth provider → **ok** + - If connection refused / DNS fails → **warning** + +### `checks/cameras.py` — RTSP unauthenticated access +- For each IP in `profile.cameras`: + 1. TCP connect to port 554 — if closed → **info** (skip) + 2. Send `OPTIONS rtsp://{ip}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n` over raw socket + 3. Parse response status line + 4. If OPTIONS returns 200, send `DESCRIBE rtsp://{ip}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n` + 5. If DESCRIBE returns 200 (stream accessible with no creds) → **critical** + 6. If DESCRIBE returns 401/403 → **ok** (auth required) + 7. If OPTIONS returns 404 / no common path accessible → **ok** + +### `checks/tls.py` — TLS certificate validity +- For each domain in `profile.domains`: + - `ssl.get_server_certificate((domain, 443))` + parse `notAfter` + - Days to expiry < 0 → **critical** (expired) + - Days to expiry < 14 → **critical** + - Days to expiry < 30 → **warning** + - Otherwise → **ok** + - Also flag if cert `CN`/`SAN` doesn't match the domain → **warning** + +### `checks/ports.py` — Public IP port exposure +- TCP connect-probe `profile.public_ip` on: 22, 23, 25, 53, 80, 443, 3306, 5432, 6379, 8080, 8443 +- Flag presence of each open port with canned risk descriptions: + - 80/443 → **info** (expected for web services) + - 22 → **warning** (SSH exposed to internet) + - 23 → **critical** (Telnet exposed) + - 53 → **warning** (DNS — run dns check to confirm resolver) + - Database ports (3306/5432/6379) → **critical** + - 8443/8080 → **warning** (alt web ports) + +--- + +## Scanner Orchestrator (`netscan/scanner.py`) + +```python +def run_scan(profile_id: int) -> int: + """ + Runs all checks for the given profile. + Returns the ScanRun PK. + Called by APScheduler jobs and TriggerScanView. + """ + profile = ScanProfile.objects.get(pk=profile_id) + run = ScanRun.objects.create(profile=profile, status='running', triggered_by=...) + + all_findings = [] + check_modules = [router, dns, ingress, cameras, tls, ports] + for mod in check_modules: + try: + findings = mod.run(profile) + all_findings.extend(findings) + except Exception as e: + # Wrap uncaught errors as a warning finding so run still completes + all_findings.append(Finding(check_name=mod.__name__, severity='warning', + title='Check errored', detail=str(e), raw={})) + + # Persist findings + ScanFinding.objects.bulk_create([...]) + + # Update run summary + summary = Counter(f.severity for f in all_findings) + run.summary = dict(summary) + run.status = 'success' + run.finished_at = now() + run.save() + + # Telegram notification + if profile.telegram_bot_token and profile.telegram_chat_id: + notify_telegram(profile, run, all_findings) + + # Update profile.last_run_at + profile.last_run_at = now() + profile.save(update_fields=['last_run_at']) + + return run.pk +``` + +--- + +## Telegram Notifications (`netscan/notifications.py`) + +```python +def notify_telegram(profile, run, findings): + """ + Sends a Telegram message if any finding meets or exceeds notify_on_severity. + Uses the Bot API sendMessage endpoint directly via requests (no library needed). + """ + threshold_order = ['ok', 'info', 'warning', 'critical'] + threshold_idx = threshold_order.index(profile.notify_on_severity) + + flagged = [f for f in findings + if threshold_order.index(f.severity) >= threshold_idx] + if not flagged: + return + + lines = [f"🔒 *NetScan Alert* — {profile.name}", + f"Run #{run.pk} finished at {run.finished_at:%Y-%m-%d %H:%M}", + f"Summary: {run.summary}", + ""] + for f in flagged[:10]: # cap at 10 to stay under TG message limit + icon = {'critical': '🔴', 'warning': '🟡', 'ok': '🟢', 'info': 'ℹ️'}[f.severity] + lines.append(f"{icon} *{f.title}*\n {f.detail[:120]}") + + if len(flagged) > 10: + lines.append(f"_...and {len(flagged)-10} more findings_") + + text = "\n".join(lines) + url = f"https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage" + requests.post(url, json={ + "chat_id": profile.telegram_chat_id, + "text": text, + "parse_mode": "Markdown" + }, timeout=10) +``` + +--- + +## APScheduler Integration (`netscan/tasks.py` + `netscan/apps.py`) + +### `tasks.py` +```python +from core.scheduler import scheduler +from apscheduler.triggers.interval import IntervalTrigger + +def schedule_profile(profile): + job_id = f'netscan_profile_{profile.pk}' + scheduler.add_job( + run_scan, + trigger=IntervalTrigger(days=profile.schedule_interval), + id=job_id, + args=[profile.pk], + replace_existing=True + ) + +def unschedule_profile(profile): + job_id = f'netscan_profile_{profile.pk}' + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) +``` + +### `apps.py` +```python +class NetscanConfig(AppConfig): + name = 'netscan' + + def ready(self): + from netscan.tasks import schedule_profile + from netscan.models import ScanProfile + for profile in ScanProfile.objects.filter(enabled=True): + schedule_profile(profile) +``` + +### `signals.py` +```python +@receiver(post_save, sender=ScanProfile) +def reschedule_on_save(sender, instance, **kwargs): + if instance.enabled: + schedule_profile(instance) + else: + unschedule_profile(instance) +``` + +--- + +## Views (`netscan/views.py`) + +All views use `LoginRequiredMixin`. + +| View | URL | Notes | +|---|---|---| +| `DashboardView` | `/ui/netscan/` | List profiles, worst-severity badge per profile, last run time, Run Now + Edit buttons | +| `ProfileCreateView` | `/ui/netscan/profile/new/` | ModelForm | +| `ProfileUpdateView` | `/ui/netscan/profile//edit/` | ModelForm | +| `ProfileDeleteView` | `/ui/netscan/profile//delete/` | Confirm page | +| `ScanRunListView` | `/ui/netscan/profile//runs/` | Paginated history, status + severity count cols | +| `ScanRunDetailView` | `/ui/netscan/run//` | Findings grouped by severity, collapsible raw JSON | +| `TriggerScanView` | `/ui/netscan/profile//trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list | +| `TestTelegramView` | `/ui/netscan/profile//test-telegram/` | POST-only; sends a test message, returns JSON | + +--- + +## Templates (`netscan/templates/netscan/`) + +All extend `base.html`, use Tailwind classes matching the existing app. + +### `dashboard.html` +- Grid of profile cards (matches mini_apps card style) +- Each card: name, schedule chip (e.g. "Every 7 days"), last run timestamp, worst-severity badge (🔴/🟡/🟢), finding count breakdown +- "Run Now" button (POST to trigger URL), "Edit" link, "History" link +- Empty state with "Create your first scan profile" CTA + +### `profile_form.html` +- Fields: Name, Schedule (dropdown: 1/3/7/30 days), Gateway IP, Public IP, Network CIDR, Auth Provider Host, Domains (textarea, one per line), Camera IPs (textarea, one per line), Telegram Bot Token, Telegram Chat ID, Notify on Severity (dropdown: warning/critical), Enabled checkbox +- "Test Telegram" button (JS fetch to TestTelegramView, shows inline success/error) + +### `run_list.html` +- Table: Started, Duration, Triggered by, Status badge, 🔴 Critical, 🟡 Warning, 🟢 OK counts, View link +- Pagination + +### `run_detail.html` +- Header: profile name, run timestamp, status, summary badges, "Re-run" button +- Three collapsible sections: Critical findings, Warnings, OK/Info +- Each finding: title, detail text; expandable "Raw" disclosure showing JSON +- Back to history link + +--- + +## URL Wiring + +### `core/urls.py` — add: +```python +path('ui/netscan/', include('netscan.urls')), +``` + +### `netscan/urls.py`: +```python +urlpatterns = [ + path('', DashboardView.as_view(), name='netscan-dashboard'), + path('profile/new/', ProfileCreateView.as_view(), name='netscan-profile-create'), + path('profile//edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'), + path('profile//delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'), + path('profile//runs/', ScanRunListView.as_view(), name='netscan-run-list'), + path('profile//trigger/', TriggerScanView.as_view(), name='netscan-trigger'), + path('profile//test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'), + path('run//', ScanRunDetailView.as_view(), name='netscan-run-detail'), +] +``` + +### `core/settings.py` — add to INSTALLED_APPS: +```python +'netscan', +``` + +### `links/mini_apps_views.py` — add to `mini_apps` list: +```python +{ + 'name': 'NetScan', + 'description': 'Scheduled home network security scanner. Checks router exposure, DNS, TLS certs, public ingress auth, and camera access.', + 'url': 'netscan-dashboard', + 'thumbnail': 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400&h=300&fit=crop', + 'icon': 'fas fa-shield-alt', + 'color': '#e74c3c' +}, +``` + +--- + +## K3s Deployment Changes (`k8s/manifest.yaml`) + +The scanner runs **inside the same Pod** as the Links app — no new container or service needed. The existing Deployment only needs two new env vars (or they can be set per-profile in the DB): + +```yaml +# Optional: global fallback Telegram credentials +- name: NETSCAN_TELEGRAM_BOT_TOKEN + valueFrom: + secretKeyRef: + name: netscan-credentials + key: telegram_bot_token +- name: NETSCAN_TELEGRAM_CHAT_ID + valueFrom: + secretKeyRef: + name: netscan-credentials + key: telegram_chat_id +``` + +Create the secret: +```bash +kubectl create secret generic netscan-credentials \ + --from-literal=telegram_bot_token=YOUR_TOKEN \ + --from-literal=telegram_chat_id=YOUR_CHAT_ID \ + -n home-apps +``` + +The Pod already runs inside the LAN (on server-3 at 192.168.1.2), so it can reach 192.168.1.1 (router), 192.168.1.70–250 (cameras), and the public IP. + +--- + +## File Checklist + +| File | Action | +|---|---| +| `netscan/__init__.py` | create (empty) | +| `netscan/apps.py` | create | +| `netscan/models.py` | create | +| `netscan/admin.py` | create (register all 3 models) | +| `netscan/checks/__init__.py` | create (empty) | +| `netscan/checks/base.py` | create | +| `netscan/checks/router.py` | create | +| `netscan/checks/dns.py` | create | +| `netscan/checks/ingress.py` | create | +| `netscan/checks/cameras.py` | create | +| `netscan/checks/tls.py` | create | +| `netscan/checks/ports.py` | create | +| `netscan/scanner.py` | create | +| `netscan/notifications.py` | create | +| `netscan/tasks.py` | create | +| `netscan/signals.py` | create | +| `netscan/forms.py` | create | +| `netscan/views.py` | create | +| `netscan/urls.py` | create | +| `netscan/migrations/0001_initial.py` | create (via makemigrations) | +| `netscan/templates/netscan/dashboard.html` | create | +| `netscan/templates/netscan/profile_form.html` | create | +| `netscan/templates/netscan/run_list.html` | create | +| `netscan/templates/netscan/run_detail.html` | create | +| `core/settings.py` | add `'netscan'` to INSTALLED_APPS | +| `core/urls.py` | add `path('ui/netscan/', include('netscan.urls'))` | +| `links/mini_apps_views.py` | add NetScan entry to mini_apps list | +| `k8s/manifest.yaml` | add env vars for Telegram credentials | + +--- + +## Verification Steps + +1. `python manage.py makemigrations netscan && python manage.py migrate` — no errors +2. `python manage.py runserver` — navigate to `/ui/netscan/` — dashboard loads +3. Create a ScanProfile via the form, ensure all fields save +4. Click "Run Now" — `ScanRun` + `ScanFinding` rows appear in DB; run_detail page shows them +5. Set schedule to 1 day, save — `scheduler.get_jobs()` shows a `netscan_profile_N` job +6. Toggle profile disabled — job is removed from scheduler +7. If Telegram configured, click "Test Telegram" — message appears in chat +8. After a scheduled run: Telegram alert arrives for any critical/warning findings +9. All views return 302 to login when accessed without auth +10. `kubectl apply -f k8s/manifest.yaml` — Pod starts cleanly with new env vars + +--- + +## Implementation Order (for LLM session in links workspace) + +1. **Phase 1** (parallel): models + migration + app registration + all check modules (no inter-dependencies) +2. **Phase 2**: scanner.py + notifications.py (depends on models + checks) +3. **Phase 3**: tasks.py + apps.py + signals.py (depends on scanner) +4. **Phase 4**: views.py + urls.py + forms.py (depends on models) +5. **Phase 5**: all 4 templates (depends on views) +6. **Phase 6**: wire into core/settings, core/urls, mini_apps_views, k8s/manifest diff --git a/README.md b/README.md index 8493f69..ed2c335 100644 --- a/README.md +++ b/README.md @@ -7,237 +7,125 @@ A URL management tool that helps you organize and access your links efficiently. - Create and manage short links - Template links with dynamic parameters - Bookmark pages with automatic title and summary extraction +- Screenshot capture for bookmarked pages - Advanced search capabilities -- API access -- Asynchronous page processing +- REST API access +- In-process background task scheduling (APScheduler) +- Network security scanner (NetScan) -## Installation +## Prerequisites -### Using Docker (Recommended) +- Python 3.12+ +- [uv](https://github.com/astral-sh/uv) — fast Python package manager +- [just](https://github.com/casey/just) — command runner (`brew install just`) +- Docker + Docker Compose (for containerised setup) -1. Prerequisites: - - Docker - - Docker Compose +## Quick Start (Local) -2. Clone the repository: - ```bash - git clone https://github.com/yourusername/url-manager.git - cd url-manager - ``` +```bash +# 1. Install dependencies and apply migrations +just install -3. Build and start services: - ```bash - ./docker.sh build - ./docker.sh start - ``` +# 2. Install Playwright browsers (for screenshot capture) +just install-browsers -The application will be available at `http://localhost:8000` +# 3. Run the full stack (Django + Tailwind watcher) +just dev-all +``` -### Docker Management Commands +The app will be available at **http://localhost:8000** -- Start all services: - ```bash - ./docker.sh start - ``` +> Run `just` (no arguments) to see all available recipes. -- Stop all services: - ```bash - ./docker.sh stop - ``` +## Local Development Commands -- Restart services: - ```bash - ./docker.sh restart - ``` +| Recipe | Description | +|---|---| +| `just dev` | Run Django server + Tailwind together | +| `just tailwind` | Run Tailwind CSS watcher (standalone) | +| `just migrate` | Apply pending migrations | +| `just makemigrations` | Generate new migrations | +| `just shell` | Open Django shell | +| `just dbshell` | Open raw DB shell | +| `just superuser` | Create a superuser | +| `just build-css` | Build minified Tailwind CSS | +| `just collectstatic` | Collect static files | +| `just test` | Run tests | +| `just compilemessages` | Compile i18n translation files | +| `just makemessages` | Extract translatable strings (zh_Hans) | -- View logs: - ```bash - ./docker.sh logs - ``` +## Docker Commands -- Run database migrations: - ```bash - ./docker.sh migrate - ``` - -- Create new migrations: - ```bash - ./docker.sh makemigrations - ``` - -- Access Django shell: - ```bash - ./docker.sh shell - ``` +| Recipe | Description | +|---|---| +| `just docker-build` | Build images | +| `just docker-start` | Start all services (detached) | +| `just docker-stop` | Stop all services | +| `just docker-restart` | Restart services | +| `just docker-logs` | Tail logs (all services) | +| `just docker-logs web` | Tail logs for a specific service | +| `just docker-shell` | Shell into the web container | +| `just docker-migrate` | Run migrations inside Docker | +| `just docker-static` | Collect static files inside Docker | +| `just docker-rebuild web` | Rebuild and restart a specific service | ### Docker Services -The application runs the following services: - -- `web`: Django web server (port 8000) -- `celery_worker`: Processes background tasks -- `celery_beat`: Schedules periodic tasks -- `redis`: Message broker and result backend - -### Manual Installation - -If you prefer not to use Docker: - -1. Install Python 3.12 and Redis - -2. Install uv: - ```bash - curl -LsSf https://astral.sh/uv/install.sh | sh - ``` - -3. Install dependencies: - ```bash - uv pip install -r requirements.txt - ``` - -4. Run migrations: - ```bash - python manage.py migrate - ``` - -5. Start the development server: - ```bash - ./run_server.sh - ``` - -6. Start Celery worker: - ```bash - ./worker.sh - ``` - -7. Start Celery beat: - ```bash - ./celery_beat.sh - ``` +- `web` — Django server on port 8000 (APScheduler runs in-process) +- `node` — Tailwind CSS compiler ## Usage ### Managing Links -1. Create a new link: - - Visit `/create/` - - Enter the original URL and desired alias - - For template links, use `{param}` syntax +1. Create a new link at `/create/` +2. Access a link at `http://localhost:8000/your-alias` +3. For template links use `{param, default=value}` syntax in the URL -2. Access a link: - - Use `http://localhost:8000/your-alias` - - For template links: `http://localhost:8000/your-alias/parameter` +### Managing Pages (Bookmarks) -### Managing Pages +1. Add a bookmark at `/ui/pages/new/` — title and summary are auto-extracted +2. Screenshots are captured automatically in the background +3. View all bookmarks at `/ui/pages/` -1. Create a new page: - - Visit `/ui/pages/new/` - - Enter the URL - - Title and summary will be automatically extracted +### Network Scanner (NetScan) -2. View all pages: - - Visit `/ui/pages/` +Available at `/ui/netscan/`. Create a scan profile to automatically monitor your home network for security issues (router exposure, DNS, TLS certs, camera access). -### API Access - -The application provides a REST API: +### API - List pages: `GET /api/pages/` -- Create page: `POST /api/pages/` +- Full API docs: `/ui/api-docs/` -Example: +## Architecture -## Monitoring Celery Tasks - -The application includes Flower for monitoring Celery tasks. You can access it in two ways: - -1. Direct access: - - Visit `http://localhost:5555` in your browser - -2. Using management script: - ```bash - ./docker.sh flower - ``` - -Flower provides: -- Real-time monitoring of Celery tasks -- Task progress and history -- Worker status and statistics -- Error tracking -- Task graphs and charts - -### Monitoring Features - -1. View all tasks: - - Active tasks - - Scheduled tasks - - Failed tasks - - Success rate - -2. Worker information: - - Status - - Resource usage - - Queue length - -3. Task details: - - Arguments - - Start time - - Runtime - - Result - - Stack traces for failed tasks - - -## Why we have `core` folder and what it is? - -The core folder is the Django project's root configuration directory, created when you first run django-admin startproject core. It serves several essential purposes: -Project-wide settings (settings.py): -Database configuration -Installed apps -Middleware -Static/media files settings -Other global settings -Root URL configuration (urls.py): -Main URL routing -Admin site URLs -Including app-specific URLs -3. WSGI/ASGI configuration: -For production deployment -Server integration -Celery configuration (celery.py): -Project-wide Celery settings -Task discovery -We can't completely remove it because Django requires this project-level configuration. The links folder is an app within the project, and you could have multiple apps in one project. -Think of it this way: -core/ = Project configuration (can contain multiple apps) -links/ = One specific app's code -This separation allows you to: -1. Have multiple apps in one project -Reuse apps across different projects -Keep project-wide settings separate from app-specific code -If you want to simplify, you could rename core to something shorter like config or core, but you can't eliminate it entirely as it's a fundamental part of Django's project structure. -Would you like me to show you how to rename it to something shorter and cleaner? - -## Development - -### Using uv for Dependency Management - -uv is a fast Python package installer and resolver. To add new dependencies: - -```bash -uv pip +``` +core/ — Django project config, APScheduler setup +links/ — Main app: links, pages, tags, posts, collections +netscan/ — Network security scanner +templates/ — Base HTML templates ``` -### Favicon +Background tasks (screenshot capture, page metadata extraction, network scans) run via **APScheduler** in-process — no separate worker or Redis needed. -This favicon was generated using the following font: +## Dependencies -- Font Title: Zen Tokyo Zoo -- Font Author: undefined -- Font Source: https://fonts.gstatic.com/s/zentokyozoo/v7/NGSyv5ffC0J_BK6aFNtr6sRv8a1uRWe9amg.ttf -- Font License: undefined) +Managed with `uv` via `pyproject.toml`. +```bash +# Add a new package +uv add package-name -### Image resizing +# Sync environment from lockfile +uv sync +``` + +## Favicon + +Generated using the Zen Tokyo Zoo font (https://fonts.gstatic.com/s/zentokyozoo/). + +## Image Resizing (Cloudflare) + +- Bind a custom domain +- Enable image resizing: https://developers.cloudflare.com/images/transform-images/ -* bind custom domain -* enable image resizing https://developers.cloudflare.com/images/transform-images/ diff --git a/agents.md b/agents.md index 444f5d0..ae4dfa8 100644 --- a/agents.md +++ b/agents.md @@ -682,44 +682,67 @@ When contributing code: ## Useful Commands Reference -### Django Management -Make sure enable enable `.venv` before running any commands through `source .venv/bin/activate` +### just (Command Runner) +The project uses [`just`](https://github.com/casey/just) as its command runner. Run `just` (no args) to list all recipes. + ```bash -python manage.py runserver # Development server (APScheduler starts automatically) -python manage.py shell # Django shell -python manage.py dbshell # Database shell -python manage.py createsuperuser # Create admin user -python manage.py test # Run tests -python manage.py collectstatic # Collect static files -python manage.py compilemessages # Compile translations -python manage.py makemessages -l zh_Hans # Extract translation strings +# Local development +just dev # Run Django + Tailwind together +just tailwind # Run Tailwind CSS watcher (standalone) +just migrate # Apply pending migrations +just makemigrations # Create new migrations +just shell # Django shell +just dbshell # Raw DB shell +just superuser # Create a superuser +just build-css # Build minified Tailwind CSS +just collectstatic # Collect static files +just test # Run tests +just compilemessages # Compile i18n translations +just makemessages # Extract translatable strings (zh_Hans) + +# Docker +just docker-build # Build images +just docker-start # Start services (detached) +just docker-stop # Stop services +just docker-logs # Tail all service logs +just docker-logs web # Tail a specific service's logs +just docker-shell # Shell into web container +just docker-migrate # Run migrations in Docker +just docker-static # Collect static in Docker +just docker-rebuild web # Rebuild + restart a service +``` + +### Direct Django Management +When you need to run `manage.py` directly, activate the venv first: +```bash +source .venv/bin/activate +uv run manage.py ``` ### APScheduler (In-Process) APScheduler starts automatically with Django. To interact with it: ```python -# In Django shell +# In Django shell (just shell) from core.scheduler import scheduler -scheduler.get_jobs() # List all scheduled jobs -scheduler.print_jobs() # Print job details -scheduler.running # Check if scheduler is running +scheduler.get_jobs() # List all scheduled jobs +scheduler.print_jobs() # Print job details +scheduler.running # Check if scheduler is running ``` ### UV Package Manager ```bash -uv pip install package_name # Install package -uv pip install -r requirements.txt # Install from requirements -uv pip freeze > requirements.txt # Export requirements -uv sync # Sync from pyproject.toml +uv add package_name # Add a new dependency +uv sync # Sync environment from pyproject.toml / uv.lock ``` ## Resources - **Django Documentation**: https://docs.djangoproject.com/ - **Django REST Framework**: https://www.django-rest-framework.org/ -- **Celery Documentation**: https://docs.celeryq.dev/ +- **APScheduler Documentation**: https://apscheduler.readthedocs.io/ - **Tailwind CSS**: https://tailwindcss.com/docs - **SwiftUI**: https://developer.apple.com/documentation/swiftui/ +- **just**: https://just.systems/man/en/ Note: - No need to generate extra summary guide or docs, unless I ask you to. diff --git a/core/apps.py b/core/apps.py index 73afd22..c6148b5 100644 --- a/core/apps.py +++ b/core/apps.py @@ -16,18 +16,92 @@ class CoreConfig(AppConfig): Initialize APScheduler when Django starts """ from core.scheduler import scheduler, start_scheduler - from links.tasks import schedule_pending_pages + from links.tasks import ( + schedule_pending_pages, schedule_pending_screenshots, + retry_stuck_image_imports, flush_click_buffer, + ) from apscheduler.triggers.interval import IntervalTrigger - + # Start the scheduler start_scheduler() - + + # Read intervals from SiteSettings (fall back to 120s if DB not ready) + try: + from links.models import SiteSettings + ss = SiteSettings.get() + pages_interval = ss.schedule_pending_pages_interval or 120 + screenshots_interval = ss.schedule_pending_screenshots_interval or 120 + except Exception: + pages_interval = 120 + screenshots_interval = 120 + # Add periodic job for checking pending pages - if not scheduler.get_job('schedule_pending_pages'): + scheduler.add_job( + schedule_pending_pages, + trigger=IntervalTrigger(seconds=pages_interval), + id='schedule_pending_pages', + replace_existing=True, + ) + logger.info(f"Scheduled periodic task: schedule_pending_pages (every {pages_interval}s)") + + # Add periodic job for recovering stuck screenshots + scheduler.add_job( + schedule_pending_screenshots, + trigger=IntervalTrigger(seconds=screenshots_interval), + id='schedule_pending_screenshots', + replace_existing=True, + ) + logger.info(f"Scheduled periodic task: schedule_pending_screenshots (every {screenshots_interval}s)") + + # Add periodic job for retrying stuck image imports (every 5 minutes) + scheduler.add_job( + retry_stuck_image_imports, + trigger=IntervalTrigger(seconds=300), + id='retry_stuck_image_imports', + replace_existing=True, + ) + logger.info("Scheduled periodic task: retry_stuck_image_imports (every 300s)") + + # Flush Redis-buffered click counts to the database (every 60 seconds) + scheduler.add_job( + flush_click_buffer, + trigger=IntervalTrigger(seconds=60), + id='flush_click_buffer', + replace_existing=True, + ) + logger.info("Scheduled periodic task: flush_click_buffer (every 60s)") + + # ── nginxmon: monthly geo DB refresh ────────────────────────────────── + try: + from nginxmon.geo import download_geo_db as refresh_geo_db scheduler.add_job( - schedule_pending_pages, - trigger=IntervalTrigger(seconds=120), - id='schedule_pending_pages', - replace_existing=True + refresh_geo_db, + trigger=IntervalTrigger(days=30), + id='refresh_geo_db', + replace_existing=True, ) - logger.info("Scheduled periodic task: schedule_pending_pages") + logger.info('nginxmon: scheduled geo DB refresh (every 30 days)') + except Exception as exc: + logger.warning('nginxmon: geo DB scheduler setup failed: %s', exc) + + # ── routermon ────────────────────────────────────────────────────────── + try: + from routermon.tasks import cleanup_old_queries + from routermon.models import RouterMonSettings + from routermon.receiver import start_receiver + + rm_settings = RouterMonSettings.get() + if rm_settings.enabled: + start_receiver(rm_settings.syslog_port) + + scheduler.add_job( + cleanup_old_queries, + trigger=IntervalTrigger(hours=6), + id='routermon_cleanup', + replace_existing=True, + ) + logger.info("routermon: scheduled cleanup job (every 6h)") + except Exception as exc: + import sys + print(f'routermon: startup error (non-fatal): {exc}', file=sys.stderr, flush=True) + logger.warning("routermon: startup error (non-fatal): %s", exc) diff --git a/core/context_processors.py b/core/context_processors.py new file mode 100644 index 0000000..d9287ef --- /dev/null +++ b/core/context_processors.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def build_info(request): + return { + 'BUILD_TIME': getattr(settings, 'BUILD_TIME', ''), + 'BUILD_VERSION': getattr(settings, 'BUILD_VERSION', ''), + } diff --git a/core/settings.py b/core/settings.py index 367af51..ffa65e3 100644 --- a/core/settings.py +++ b/core/settings.py @@ -21,22 +21,13 @@ INSTALLED_APPS = [ 'simplemde', 'markdown', # 只需要基本的markdown包 'invest', + 'netscan', + 'nginxmon', + 'routermon', ] ROOT_URLCONF = 'core.urls' -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'core.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', -] - TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -48,6 +39,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'core.context_processors.build_info', ], }, }, @@ -57,7 +49,8 @@ TEMPLATES = [ # 添加以下基本设置(如果尚未存在) SECRET_KEY = 'your-secret-key-here' # 请更改为一个安全的随机值 -DEBUG = True + +DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes') ALLOWED_HOSTS = ['*'] # 数据库设置(使用默认的SQLite配置) @@ -114,6 +107,24 @@ STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000').split(',') +# Redis cache +REDIS_URL = os.environ.get('REDIS_URL', 'redis://192.168.1.2:6379/0') + +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': REDIS_URL, + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + }, + 'TIMEOUT': 300, + } +} + +# Store sessions in Redis instead of the database +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' +SESSION_CACHE_ALIAS = 'default' + # SimpleMDE 配置 SIMPLEMDE_OPTIONS = { 'placeholder': 'Type here...', @@ -129,6 +140,12 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'data', 'media') MUSIC_ROOT = os.path.join(BASE_DIR, 'data', 'music') +# File uploads folder — override via FILE_UPLOADS_FOLDER env var. +# Defaults to ~/Downloads locally; set to /uploads on k8s. +FILE_UPLOADS_FOLDER = os.environ.get('FILE_UPLOADS_FOLDER', os.path.expanduser('~/Downloads')) +IMAGES_FOLDER = os.environ.get('IMAGES_FOLDER', os.path.expanduser('~/Pictures')) + + LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -150,15 +167,6 @@ LOGGING = { }, } -REST_FRAMEWORK = { - 'DEFAULT_RENDERER_CLASSES': [ - 'rest_framework.renderers.JSONRenderer', - ], - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 10, - 'UNAUTHENTICATED_USER': None, # 添加这行 -} - LANGUAGE_URL_MAP = { 'en': 'en', 'zh-hans': 'zh', @@ -178,6 +186,7 @@ LOCALE_INDEPENDENT_PATHS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'core.middleware.CustomLocaleMiddleware', # 使用自定义中间件 'django.middleware.common.CommonMiddleware', @@ -191,6 +200,8 @@ REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', ], + 'DEFAULT_AUTHENTICATION_CLASSES': [], + 'DEFAULT_PERMISSION_CLASSES': [], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 10, 'UNAUTHENTICATED_USER': None, @@ -218,21 +229,12 @@ R2_CUSTOM_DOMAIN = os.environ.get('R2_CUSTOM_DOMAIN') CRAWL4AI_API_URL = os.environ.get('CRAWL4AI_API_URL', 'https://crawl-api.junv.cc') CRAWL4AI_ENABLED = os.environ.get('CRAWL4AI_ENABLED', 'True').lower() in ('true', '1', 'yes') -# For debugging -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'level': 'DEBUG', - }, - }, - 'loggers': { - 'links': { - 'handlers': ['console'], - 'level': 'DEBUG', - 'propagate': True, - }, - }, +# Build metadata (injected at Docker image build time) +BUILD_TIME = os.environ.get('BUILD_TIME', '') +BUILD_VERSION = os.environ.get('BUILD_VERSION', '') + +LOGGING['loggers']['links'] = { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, } diff --git a/core/urls.py b/core/urls.py index 7b4242b..a57227e 100644 --- a/core/urls.py +++ b/core/urls.py @@ -3,15 +3,35 @@ from django.urls import path, include from django.conf import settings from django.conf.urls.static import static from django.views.static import serve +from django.views.generic import TemplateView +from django.http import FileResponse, Http404 from links.views import LinkDetailView, LinkUpdateView, CustomLinkView +from links.file_views import PublicFileView, import_image_view from django.urls import path, include, re_path from django.conf.urls.i18n import i18n_patterns +import os + + +def _serve_static_file(filename, content_type): + """Serve a file directly from the committed static/ directory.""" + def view(request): + path = os.path.join(settings.BASE_DIR, 'static', filename) + try: + return FileResponse(open(path, 'rb'), content_type=content_type) + except FileNotFoundError: + raise Http404 + return view + urlpatterns = [ + # LLM-friendly discovery endpoints — must be FIRST before any catch-all routes + path('llms.txt', _serve_static_file('llms.txt', 'text/plain; charset=utf-8'), name='llms-txt'), + path('.well-known/ai-plugin.json', _serve_static_file('.well-known/ai-plugin.json', 'application/json'), name='ai-plugin-json'), + path('admin/', admin.site.urls), # Add API URLs before locale URLs path('api/', include('links.api_urls')), # New line for API routes - path('api/invest/', include('invest.urls', namespace='invest-api')), + path('api/invest/', include('invest.api_urls')), # Media files path('media/', serve, { 'document_root': settings.MEDIA_ROOT, @@ -28,6 +48,24 @@ urlpatterns = [ path('custom//edit/', LinkUpdateView.as_view(), name='custom_link_update'), path('invest/', include('invest.urls')), + + # Include netscan and files BEFORE links.urls to prevent the alias catch-all from intercepting them + path('ui/netscan/', include('netscan.urls')), + path('ui/nginxmon/', include('nginxmon.urls')), + path('ui/routermon/', include('routermon.urls')), + path('ui/files/', include('links.file_urls')), + + # Import external image by URL — /import/images/ (also plural alias) + path('import/images/', import_image_view, name='import-image'), + path('imports/images/', import_image_view, name='imports-image'), + + # Public file access — /public/files/{uuid}-{filename} + re_path( + r'^public/files/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P.+)$', + PublicFileView.as_view(), + name='public-file', + ), + # Include main app URLs with locale path('', include('links.urls')), ] diff --git a/data/db.sqlite3 b/data/db.sqlite3 deleted file mode 100644 index 0c49487..0000000 Binary files a/data/db.sqlite3 and /dev/null differ diff --git a/invest/migrations/0001_initial.py b/invest/migrations/0001_initial.py new file mode 100644 index 0000000..2db363d --- /dev/null +++ b/invest/migrations/0001_initial.py @@ -0,0 +1,105 @@ +# Generated by Django 5.2.12 on 2026-04-18 04:07 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Portfolio', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('account_id', models.CharField(blank=True, max_length=50)), + ('base_currency', models.CharField(default='USD', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='PriceCache', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticker', models.CharField(max_length=20)), + ('exchange', models.CharField(blank=True, default='', max_length=10)), + ('price', models.DecimalField(decimal_places=6, max_digits=20)), + ('currency', models.CharField(default='USD', max_length=10)), + ('change_percent', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)), + ('prev_close', models.DecimalField(blank=True, decimal_places=6, max_digits=20, null=True)), + ('fetched_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'indexes': [models.Index(fields=['ticker', 'exchange', 'fetched_at'], name='invest_pric_ticker_69ac3b_idx')], + }, + ), + migrations.CreateModel( + name='Report', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('content', models.TextField()), + ('period_start', models.DateField()), + ('period_end', models.DateField()), + ('generated_at', models.DateTimeField(auto_now_add=True)), + ('report_type', models.CharField(choices=[('WEEKLY', 'Weekly'), ('MANUAL', 'Manual')], default='WEEKLY', max_length=10)), + ('valuation_snapshot', models.JSONField(default=dict, help_text='Snapshot of prices and holdings at time of report generation')), + ('portfolio', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='invest.portfolio')), + ], + options={ + 'ordering': ['-generated_at'], + }, + ), + migrations.CreateModel( + name='Stock', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticker', models.CharField(max_length=20)), + ('exchange', models.CharField(blank=True, default='', help_text='Exchange code, e.g. NASDAQ, HKG. Empty = US market default.', max_length=10)), + ('company_name', models.CharField(blank=True, max_length=200)), + ('shares_held', models.DecimalField(decimal_places=6, default=Decimal('0'), max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])), + ('avg_cost', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Weighted average cost per share in quote_currency', max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])), + ('quote_currency', models.CharField(default='USD', max_length=10)), + ('is_active', models.BooleanField(default=True)), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('portfolio', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='invest.portfolio')), + ], + options={ + 'ordering': ['ticker'], + 'unique_together': {('portfolio', 'ticker', 'exchange')}, + }, + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tx_type', models.CharField(choices=[('BUY', 'Buy'), ('SELL', 'Sell')], max_length=4)), + ('date', models.DateField()), + ('price_per_share', models.DecimalField(decimal_places=6, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])), + ('shares', models.DecimalField(decimal_places=6, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.000001'))])), + ('fee', models.DecimalField(decimal_places=6, default=Decimal('0'), max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])), + ('notes', models.TextField(blank=True)), + ('source', models.CharField(default='manual', help_text="Origin of transaction: 'manual', 'ai', 'import'", max_length=20)), + ('idempotency_key', models.CharField(blank=True, help_text='Unique key to prevent duplicate AI writes', max_length=100, null=True, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('stock', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='invest.stock')), + ], + options={ + 'ordering': ['date', 'created_at'], + }, + ), + ] diff --git a/justfile b/justfile new file mode 100644 index 0000000..208f02e --- /dev/null +++ b/justfile @@ -0,0 +1,166 @@ +# URL Manager — justfile +# Install just: brew install just +# Run `just` to see all available recipes. + +set shell := ["bash", "-c"] + +# Show available recipes +default: + @just --list + +# ── Setup ───────────────────────────────────────────────────────────────────── + +# Install Python dependencies and set up the project for first use +install: + uv sync + source .venv/bin/activate && uv run manage.py migrate + mkdir -p media/screenshots data + +# Install Playwright browsers (needed for screenshot capture) +install-browsers: + source .venv/bin/activate && playwright install chromium + +# ── Local Development ───────────────────────────────────────────────────────── + +# Run Django server + Tailwind CSS + stern nginx ingestion together +dev: + #!/usr/bin/env bash + set -eu + source .venv/bin/activate + PIDS=() + cleanup() { + echo "Stopping background processes…" + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + } + trap cleanup EXIT INT TERM + + npm run dev & + PIDS+=($!) + + uv run manage.py nginxmon_stern & + PIDS+=($!) + + uv run manage.py runserver 0.0.0.0:8000 + +# Stream nginx logs via stern only (without starting Django) +stern: + #!/usr/bin/env bash + source .venv/bin/activate + uv run manage.py nginxmon_stern + +# Run Tailwind CSS in watch mode (standalone) +tailwind: + npm run dev + +# ── Database ────────────────────────────────────────────────────────────────── + +# Apply all pending migrations +migrate: + source .venv/bin/activate && uv run manage.py migrate + +# Create new migrations from model changes +makemigrations: + source .venv/bin/activate && uv run manage.py makemigrations + +# Open Django shell +shell: + source .venv/bin/activate && uv run manage.py shell + +# Open raw database shell +dbshell: + source .venv/bin/activate && uv run manage.py dbshell + +# Create a Django superuser +superuser: + source .venv/bin/activate && uv run manage.py createsuperuser + +# ── Static Assets ───────────────────────────────────────────────────────────── + +# Copy vendor JS/CSS from node_modules to static/vendor/ +vendor: + node scripts/copy-vendor.js + +# Build Tailwind CSS (minified, for production) +build-css: + npm run build:css + +# Build the React search component with Vite +build-search: + npm run build:search + +# Build all frontend assets: vendor copy + Tailwind + Vite (full production build) +build: + npm run build + +# Collect all static files into staticfiles/ +collectstatic: + source .venv/bin/activate && uv run manage.py collectstatic --no-input + +# ── i18n ────────────────────────────────────────────────────────────────────── + +# Compile translation files +compilemessages: + source .venv/bin/activate && uv run manage.py compilemessages + +# Extract translatable strings for zh_Hans +makemessages: + source .venv/bin/activate && uv run manage.py makemessages -l zh_Hans + +# ── Testing ─────────────────────────────────────────────────────────────────── + +# Run tests +test: + source .venv/bin/activate && pytest + +# Run tests with coverage +test-cov: + source .venv/bin/activate && pytest --cov=links --cov-report=term-missing + +# ── Docker ──────────────────────────────────────────────────────────────────── + +# Build Docker images +docker-build: + docker-compose build + +# Start all Docker services (detached) +docker-start: + mkdir -p media/screenshots + chmod -R 777 media + docker-compose up -d + @echo "App running at http://localhost:8000" + +# Stop all Docker services +docker-stop: + docker-compose down + +# Restart Docker services +docker-restart: + docker-compose restart + +# Tail logs for all services (or a specific one: just docker-logs web) +docker-logs service="": + #!/usr/bin/env bash + if [ -n "{{ service }}" ]; then + docker-compose logs -f {{ service }} + else + docker-compose logs -f + fi + +# Open a shell inside the web container +docker-shell: + docker-compose exec web bash + +# Run migrations inside Docker +docker-migrate: + docker-compose exec web uv run manage.py makemigrations + docker-compose exec web uv run manage.py migrate + +# Collect static files inside Docker +docker-static: + docker-compose exec web uv run manage.py collectstatic --no-input + +# Rebuild and restart a specific service (e.g.: just docker-rebuild web) +docker-rebuild service="web": + docker-compose up -d --build {{ service }} diff --git a/k8s/manifest.template.yaml b/k8s/manifest.template.yaml new file mode 100644 index 0000000..652c9d9 --- /dev/null +++ b/k8s/manifest.template.yaml @@ -0,0 +1,326 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: feiniu-images-nfs-apps +spec: + capacity: + storage: 100Gi + accessModes: + - ReadWriteMany + nfs: + server: 192.168.1.5 + path: "/fs/1000/nfs/data/images" + +--- + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "feiniu-images-nfs-apps" +spec: + storageClassName: "" + volumeName: feiniu-images-nfs-apps + accessModes: + - ReadWriteMany + resources: + requests: + storage: 100Gi + +--- + +apiVersion: v1 +kind: PersistentVolume +metadata: + name: uploads-nfs-apps +spec: + capacity: + storage: 1000Gi + accessModes: + - ReadWriteMany + nfs: + server: 192.168.1.5 + path: "/fs/1000/nfs/uploads" + +--- + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "uploads-nfs-apps" +spec: + storageClassName: "" + volumeName: uploads-nfs-apps + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1000Gi + +# --- +# apiVersion: v1 +# kind: PersistentVolumeClaim +# metadata: +# name: links-pvc +# spec: +# accessModes: +# - ReadWriteOnce +# storageClassName: nfs-client +# resources: +# requests: +# storage: 2Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: links + labels: + tags.datadoghq.com/env: "prod" + tags.datadoghq.com/service: "links" +spec: + selector: + matchLabels: + app: links + replicas: 1 + strategy: + type: Recreate + template: + metadata: + labels: + app: links + tags.datadoghq.com/env: "prod" + tags.datadoghq.com/service: "links" + spec: + serviceAccountName: links + volumes: + - name: downloads + persistentVolumeClaim: + claimName: feiniu-images-nfs-apps + - name: data + persistentVolumeClaim: + claimName: links-local-pvc + - name: uploads + persistentVolumeClaim: + claimName: uploads-nfs-apps + - name: cache + emptyDir: {} + initContainers: + - name: links-init + image: "ghcr.io/wahyd4/links:__IMAGE_TAG__" + command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] + volumeMounts: + - name: data + mountPath: /app/data + containers: + - name: links + image: "ghcr.io/wahyd4/links:__IMAGE_TAG__" + securityContext: + runAsUser: 1000 + imagePullPolicy: Always + volumeMounts: + - name: data + mountPath: /app/data + - name: cache + mountPath: /app/.cache + - name: downloads + mountPath: /images + - name: uploads + mountPath: /uploads + env: + - name : DEBUG + value: "false" + - name: FILE_UPLOADS_FOLDER + value: "/uploads" + - name: R2_CUSTOM_DOMAIN + value: home-links-prod.junv.cc + - name: DB_HOST + value: new-postgres-postgresql.db.svc.cluster.local + - name: DB_NAME + value: badges + - name: DB_USERNAME + value: postgres + # - name: DB_PASSWORD + # valueFrom: + # { secretKeyRef: { name: database-credentials, key: password } } + - name: CSRF_TRUSTED_ORIGINS + value: "https://to.junv.cc,https://go.junv.cc,http://go" + - name: UV_CACHE_DIR + value: "/app/.cache/uv" + - name: R2_BUCKET_NAME + value: "home-links-prod" + - name: R2_ENDPOINT_URL + value: https://d39b5aca439164602c01f7af2a58d4bf.r2.cloudflarestorage.com + - name: IMAGES_FOLDER + value: "/images" + - name: R2_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: r2-credentials + key: access_key + - name: R2_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: r2-credentials + key: key_id + - name: REDIS_URL + value: "redis://redis.db.svc.cluster.local:6379/0" + - name: CRAWL4AI_API_URL + value: "http://crawl4ai.ai.svc.cluster.local:80" + - name: CRAWL4AI_ENABLED + value: "true" + - name: QDRANT_SYNC_ENABLED + value: "true" + - name: QDRANT_HOST + value: "192.168.1.2" + - name: OLLAMA_URL + value: "http://ollama-service.ollama.svc.cluster.local:11434" + ports: + - containerPort: 8000 + name: links-port + protocol: TCP + - containerPort: 5514 + name: syslog-udp + protocol: UDP + hostPort: 5514 + resources: + requests: + cpu: 200m + memory: 400Mi + limits: + cpu: 1200m + memory: 2Gi + imagePullSecrets: + - name: github-image-pull-secret +--- +apiVersion: v1 +kind: Service +metadata: + name: links + labels: + app: links +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: links-port + protocol: TCP + name: web + selector: + app: links + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: links-ingress + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "true" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/proxy-body-size: 1024m + nginx.ingress.kubernetes.io/auth-url: "https://pass.junv.cc/oauth2/auth" + nginx.ingress.kubernetes.io/auth-signin: "https://pass.junv.cc/oauth2/start?rd=https://$host$escaped_request_uri" + # Skip authentication for /public, but looks like doesn't work + # nginx.ingress.kubernetes.io/auth-snippet: | + # if ($request_uri ~ "/public") { + # return 200; + # } +spec: + ingressClassName: nginx + tls: + - hosts: + - to.junv.cc + - go.junv.cc + secretName: links-tls + rules: + - host: go.junv.cc + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + - host: to.junv.cc + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: links + port: + number: 80 +--- + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: go-links-ingress + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "false" + nginx.ingress.kubernetes.io/proxy-body-size: 1024m +spec: + ingressClassName: nginx + rules: + - host: go + http: + paths: + - backend: + service: + name: links + port: + number: 80 + path: / + pathType: Prefix + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: public-links-ingress + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "true" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/proxy-body-size: 100m +spec: + tls: + - hosts: + - xgo.junv.cc + secretName: public-links-tls + rules: + - host: xgo.junv.cc + http: + paths: + - path: /public + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + - path: /static + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + - path: /favicon.ico + pathType: Prefix + backend: + service: + name: links + port: + number: 80 + +--- +# ServiceAccount for the links pod +apiVersion: v1 +kind: ServiceAccount +metadata: + name: links + namespace: apps diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 479994e..4000181 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -26,6 +26,35 @@ spec: requests: storage: 100Gi +--- + +apiVersion: v1 +kind: PersistentVolume +metadata: + name: uploads-nfs-apps +spec: + capacity: + storage: 1000Gi + accessModes: + - ReadWriteMany + nfs: + server: 192.168.1.5 + path: "/fs/1000/nfs/uploads" + +--- + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "uploads-nfs-apps" +spec: + storageClassName: "" + volumeName: uploads-nfs-apps + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1000Gi # --- # apiVersion: v1 @@ -52,6 +81,8 @@ spec: matchLabels: app: links replicas: 1 + strategy: + type: Recreate template: metadata: labels: @@ -59,6 +90,7 @@ spec: tags.datadoghq.com/env: "prod" tags.datadoghq.com/service: "links" spec: + serviceAccountName: links volumes: - name: downloads persistentVolumeClaim: @@ -66,18 +98,21 @@ spec: - name: data persistentVolumeClaim: claimName: links-local-pvc + - name: uploads + persistentVolumeClaim: + claimName: uploads-nfs-apps - name: cache emptyDir: {} initContainers: - name: links-init - image: "ghcr.io/wahyd4/links:main" - command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py collectstatic --noinput && uv run manage.py rebuild_search_index"] + image: "ghcr.io/wahyd4/links:1.0.296" + command: ["sh", "-c", "uv run manage.py migrate && uv run manage.py rebuild_search_index"] volumeMounts: - name: data mountPath: /app/data containers: - name: links - image: "ghcr.io/wahyd4/links:main" + image: "ghcr.io/wahyd4/links:1.0.296" securityContext: runAsUser: 1000 imagePullPolicy: Always @@ -88,9 +123,13 @@ spec: mountPath: /app/.cache - name: downloads mountPath: /images + - name: uploads + mountPath: /uploads env: - name : DEBUG value: "false" + - name: FILE_UPLOADS_FOLDER + value: "/uploads" - name: R2_CUSTOM_DOMAIN value: home-links-prod.junv.cc - name: DB_HOST @@ -122,6 +161,8 @@ spec: secretKeyRef: name: r2-credentials key: key_id + - name: REDIS_URL + value: "redis://redis.db.svc.cluster.local:6379/0" - name: CRAWL4AI_API_URL value: "http://crawl4ai.ai.svc.cluster.local:80" - name: CRAWL4AI_ENABLED @@ -136,13 +177,17 @@ spec: - containerPort: 8000 name: links-port protocol: TCP + - containerPort: 5514 + name: syslog-udp + protocol: UDP + hostPort: 5514 resources: requests: cpu: 200m memory: 400Mi limits: - cpu: 1000m - memory: 1000Mi + cpu: 1200m + memory: 2Gi imagePullSecrets: - name: github-image-pull-secret --- @@ -271,3 +316,11 @@ spec: name: links port: number: 80 + +--- +# ServiceAccount for the links pod +apiVersion: v1 +kind: ServiceAccount +metadata: + name: links + namespace: apps diff --git a/links/api_urls.py b/links/api_urls.py index 872621f..4896d45 100644 --- a/links/api_urls.py +++ b/links/api_urls.py @@ -3,12 +3,15 @@ from rest_framework.routers import DefaultRouter from . import page_views from . import post_views from . import api_views +from . import file_views # Create a router and register our viewsets with it router = DefaultRouter(trailing_slash=False) +router.register('links', api_views.LinkViewSet, basename='api-links') router.register('pages', page_views.PageViewSet, basename='api-pages') router.register('posts', post_views.PostViewSet, basename='api-posts') router.register('music', api_views.MusicViewSet, basename='api-music') +router.register('files', file_views.FileUploadViewSet, basename='api-files') # The API URLs are determined automatically by the router urlpatterns = [ diff --git a/links/api_views.py b/links/api_views.py index 41f9940..a7f7e79 100644 --- a/links/api_views.py +++ b/links/api_views.py @@ -1,8 +1,9 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from .models import ImageCollection, Image -from .serializers import ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer +from rest_framework.pagination import PageNumberPagination +from .models import Link, ImageCollection, Image +from .serializers import LinkSerializer, ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer from .storage import R2Storage import uuid import logging @@ -12,6 +13,31 @@ import os logger = logging.getLogger(__name__) + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + + +class LinkViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = LinkSerializer + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + return Link.objects.prefetch_related('tags').order_by('-created_at') + + @action(detail=False, methods=['get'], url_path='most-visited') + def most_visited(self, request): + """Return links sorted by click_count descending.""" + queryset = Link.objects.prefetch_related('tags').filter(click_count__gt=0).order_by('-click_count') + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + class ImageCollectionViewSet(viewsets.ModelViewSet): queryset = ImageCollection.objects.all() serializer_class = ImageCollectionSerializer diff --git a/links/apps.py b/links/apps.py index e133061..c1bca6b 100644 --- a/links/apps.py +++ b/links/apps.py @@ -8,3 +8,176 @@ class LinksConfig(AppConfig): def ready(self): # Import signals to register them import links.signals + self._register_job_types() + + def _register_job_types(self): + from . import job_registry + if job_registry.all_types(): + return # already registered (e.g. double-init in test runner) + + from .models import Screenshot, Page, FileUpload + from django.urls import reverse + + # ── Screenshots ────────────────────────────────────────────────── + def ss_stats(): + return { + 'total': Screenshot.objects.count(), + 'pending': Screenshot.objects.filter(status=Screenshot.Status.PENDING).count(), + 'processing': Screenshot.objects.filter(status=Screenshot.Status.PROCESSING).count(), + 'completed': Screenshot.objects.filter(status=Screenshot.Status.COMPLETED).count(), + 'failed': Screenshot.objects.filter(status=Screenshot.Status.FAILED).count(), + } + + def ss_queryset(sf): + qs = Screenshot.objects.select_related('page').order_by('-updated_at') + return qs if sf == 'all' else qs.filter(status=sf) + + def ss_serialize(obj): + return { + 'id': str(obj.id), + 'title': (obj.page.title or obj.page.url) if obj.page else '?', + 'detail_url': reverse('page-detail', args=[obj.page.pk]) if obj.page else '#', + 'status': obj.status, + 'retry': obj.retry_count, + 'retry_max': 3, + 'error': obj.error or '', + 'updated_at': obj.updated_at, + 'extra': {}, + } + + job_registry.register({ + 'id': 'screenshots', + 'label': 'Screenshots', + 'icon_color': 'text-indigo-500', + 'icon_path': ( + 'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86' + 'a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2' + ' 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z' + ), + 'title_label': 'Page', + 'status_choices': [ + ('all', 'All'), ('pending', 'Pending'), ('processing', 'Processing'), + ('completed', 'Completed'), ('failed', 'Failed'), + ], + 'columns': ['id', 'title', 'status', 'retry', 'error', 'updated'], + 'get_stats': ss_stats, + 'get_queryset': ss_queryset, + 'serialize': ss_serialize, + 'bulk_actions': { + 'retry': 'bulk_retry_screenshots', + 'fail': 'bulk_fail_screenshots', + 'delete': 'bulk_delete_screenshots', + }, + }) + + # ── Page Processing ───────────────────────────────────────────── + def pg_stats(): + return { + 'total': Page.objects.count(), + 'pending': Page.objects.filter(process_status=Page.ProcessStatus.PENDING).count(), + 'processing': Page.objects.filter(process_status=Page.ProcessStatus.PROCESSING).count(), + 'completed': Page.objects.filter(process_status=Page.ProcessStatus.COMPLETED).count(), + 'failed': Page.objects.filter(process_status=Page.ProcessStatus.FAILED).count(), + } + + def pg_queryset(sf): + qs = Page.objects.order_by('-updated_at') + return qs if sf == 'all' else qs.filter(process_status=sf) + + def pg_serialize(obj): + return { + 'id': str(obj.id), + 'title': obj.title or obj.url, + 'detail_url': reverse('page-detail', args=[obj.pk]), + 'status': obj.process_status, + 'retry': obj.retry_count, + 'retry_max': 3, + 'error': obj.error_message or '', + 'updated_at': obj.updated_at, + 'extra': {}, + } + + job_registry.register({ + 'id': 'pages', + 'label': 'Page Processing', + 'icon_color': 'text-purple-500', + 'icon_path': ( + 'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2' + 'V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2' + ), + 'title_label': 'Title / URL', + 'status_choices': [ + ('all', 'All'), ('pending', 'Pending'), ('processing', 'Processing'), + ('completed', 'Completed'), ('failed', 'Failed'), + ], + 'columns': ['id', 'title', 'status', 'retry', 'error', 'updated'], + 'get_stats': pg_stats, + 'get_queryset': pg_queryset, + 'serialize': pg_serialize, + 'bulk_actions': { + 'retry': 'bulk_retry_pages', + 'fail': 'bulk_fail_pages', + 'delete': 'bulk_delete_pages', + }, + }) + + # ── Image Imports ────────────────────────────────────────────── + def ii_stats(): + base = FileUpload.objects.filter(source_url__isnull=False).exclude(source_url='') + return { + 'total': base.count(), + 'pending': base.filter(size=0).count(), + 'completed': base.filter(size__gt=0).count(), + } + + def ii_queryset(sf): + base = FileUpload.objects.filter( + source_url__isnull=False, + ).exclude(source_url='').order_by('-updated_at') + if sf == 'pending': + return base.filter(size=0) + if sf == 'completed': + return base.filter(size__gt=0) + return base + + def ii_serialize(obj): + return { + 'id': str(obj.id), + 'title': obj.name, + 'detail_url': getattr(obj, 'download_url', '#') or '#', + 'status': 'completed' if obj.size > 0 else 'pending', + 'retry': None, + 'retry_max': None, + 'error': '', + 'updated_at': obj.updated_at, + 'extra': { + 'source_url': obj.source_url or '', + 'formatted_size': ( + getattr(obj, 'formatted_size', '') if obj.size > 0 else '' + ), + }, + } + + job_registry.register({ + 'id': 'image_imports', + 'label': 'Image Imports', + 'icon_color': 'text-green-500', + 'icon_path': ( + 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828' + ' 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12' + 'a2 2 0 002 2z' + ), + 'title_label': 'Filename', + 'status_choices': [ + ('all', 'All'), ('pending', 'Pending'), ('completed', 'Done'), + ], + 'columns': ['id', 'title', 'source_url', 'status', 'size', 'updated'], + 'get_stats': ii_stats, + 'get_queryset': ii_queryset, + 'serialize': ii_serialize, + 'bulk_actions': { + 'retry': 'bulk_retry_image_imports', + 'delete': 'bulk_delete_image_imports', + }, + }) + diff --git a/links/collection_views.py b/links/collection_views.py index 8a08bca..80b88b3 100644 --- a/links/collection_views.py +++ b/links/collection_views.py @@ -11,6 +11,11 @@ class CollectionListView(ListView): context_object_name = 'collections' paginate_by = 12 + def get_template_names(self): + if self.request.headers.get('HX-Request'): + return ['links/includes/collection_list_items.html'] + return [self.template_name] + class CollectionDetailView(DetailView): model = ImageCollection template_name = 'links/collection_detail.html' diff --git a/links/file_urls.py b/links/file_urls.py new file mode 100644 index 0000000..676fa7c --- /dev/null +++ b/links/file_urls.py @@ -0,0 +1,16 @@ +from django.urls import path, re_path +from . import file_views + +urlpatterns = [ + path('', file_views.FileListView.as_view(), name='file-list'), + path('upload/', file_views.FileUploadView.as_view(), name='file-upload'), + # /ui/files/{uuid}-{filename} — filename is cosmetic, lookup is by uuid only + re_path( + r'^(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P.+)$', + file_views.FileDownloadView.as_view(), + name='file-download', + ), + path('/delete/', file_views.FileDeleteView.as_view(), name='file-delete'), + path('/toggle-public/', file_views.FileTogglePublicView.as_view(), name='file-toggle-public'), + path('/set-expiry/', file_views.FileSetExpiryView.as_view(), name='file-set-expiry'), +] diff --git a/links/file_views.py b/links/file_views.py new file mode 100644 index 0000000..0dd180f --- /dev/null +++ b/links/file_views.py @@ -0,0 +1,275 @@ +import json +import mimetypes +import os +import secrets +import logging +from pathlib import Path + +from django.conf import settings +from django.db.models import F +from django.http import FileResponse, JsonResponse, Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.dateparse import parse_datetime +from django.views import View + +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.response import Response + +from .models import FileUpload +from .serializers import FileUploadSerializer + +logger = logging.getLogger(__name__) + + +def _get_upload_folder(): + folder = settings.FILE_UPLOADS_FOLDER + Path(folder).mkdir(parents=True, exist_ok=True) + return folder + + +def _save_uploaded_file(f): + """Save an in-memory upload to FILE_UPLOADS_FOLDER and return a FileUpload instance.""" + folder = _get_upload_folder() + ext = Path(f.name).suffix.lower() + stored_name = f"{secrets.token_hex(16)}{ext}" + dest_path = os.path.join(folder, stored_name) + with open(dest_path, 'wb') as dst: + for chunk in f.chunks(): + dst.write(chunk) + mime_type = f.content_type or mimetypes.guess_type(f.name)[0] or 'application/octet-stream' + return FileUpload.objects.create( + name=f.name, + stored_name=stored_name, + mime_type=mime_type, + size=f.size, + ) + + +# ── UI Views ────────────────────────────────────────────────────────────────── + +class FileListView(View): + def get(self, request): + files = FileUpload.objects.all() + return render(request, 'links/files/list.html', {'files': files}) + + +class FileUploadView(View): + def post(self, request): + is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' + uploaded = request.FILES.getlist('files') + if not uploaded: + if is_ajax: + return JsonResponse({'error': 'No files provided'}, status=400) + return redirect('file-list') + results = [] + for f in uploaded: + record = _save_uploaded_file(f) + results.append({'id': str(record.pk), 'name': record.name, 'size': record.size}) + if is_ajax: + return JsonResponse({'uploaded': results}) + return redirect('file-list') + + +class FileDownloadView(View): + def get(self, request, pk, filename=''): + record = get_object_or_404(FileUpload, pk=pk) + if not os.path.exists(record.file_path): + raise Http404("File not found on disk") + FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1) + disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"' + response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type) + response['Content-Disposition'] = disposition + return response + + +class FileDeleteView(View): + def post(self, request, pk): + record = get_object_or_404(FileUpload, pk=pk) + if os.path.exists(record.file_path): + os.remove(record.file_path) + record.delete() + return redirect('file-list') + + +class FileTogglePublicView(View): + def post(self, request, pk): + record = get_object_or_404(FileUpload, pk=pk) + if record.is_public: + record.is_public = False + record.save(update_fields=['is_public', 'updated_at']) + return JsonResponse({'is_public': False, 'public_url': None}) + record.is_public = True + record.save(update_fields=['is_public', 'updated_at']) + return JsonResponse({ + 'is_public': True, + 'public_url': record.public_url, + }) + + +class FileSetExpiryView(View): + def post(self, request, pk): + record = get_object_or_404(FileUpload, pk=pk) + try: + data = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'Invalid JSON'}, status=400) + expires_at = data.get('expires_at') + if expires_at: + dt = parse_datetime(expires_at) + if not dt: + return JsonResponse({'error': 'Invalid datetime format. Use ISO 8601.'}, status=400) + record.expires_at = dt + else: + record.expires_at = None + record.save(update_fields=['expires_at', 'updated_at']) + return JsonResponse({ + 'expires_at': record.expires_at.isoformat() if record.expires_at else None, + 'is_expired': record.is_expired, + }) + + +class PublicFileView(View): + def get(self, request, pk, filename=''): + record = get_object_or_404(FileUpload, pk=pk, is_public=True) + if record.is_expired: + raise Http404("This public link has expired") + if not os.path.exists(record.file_path): + raise Http404("File not found") + FileUpload.objects.filter(pk=record.pk).update(download_count=F('download_count') + 1) + disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"' + response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type) + response['Content-Disposition'] = disposition + return response + + +# ── REST API ViewSet ────────────────────────────────────────────────────────── + +class FileUploadViewSet(viewsets.ModelViewSet): + queryset = FileUpload.objects.all() + serializer_class = FileUploadSerializer + parser_classes = [MultiPartParser, FormParser, JSONParser] + http_method_names = ['get', 'post', 'delete', 'head', 'options'] + + def create(self, request, *args, **kwargs): + uploaded = request.FILES.getlist('files') + if not uploaded: + return Response({'error': 'No files provided. Use files[] field.'}, status=status.HTTP_400_BAD_REQUEST) + created = [_save_uploaded_file(f) for f in uploaded] + serializer = self.get_serializer(created, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + record = self.get_object() + if os.path.exists(record.file_path): + os.remove(record.file_path) + record.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=['post'], url_path='toggle-public') + def toggle_public(self, request, pk=None): + record = self.get_object() + if record.is_public: + record.is_public = False + record.save(update_fields=['is_public', 'updated_at']) + return Response({'is_public': False, 'public_url': None}) + record.is_public = True + record.save(update_fields=['is_public', 'updated_at']) + return Response({ + 'is_public': True, + 'public_url': record.public_url, + }) + + @action(detail=True, methods=['post'], url_path='set-expiry') + def set_expiry(self, request, pk=None): + record = self.get_object() + expires_at = request.data.get('expires_at') + if expires_at: + dt = parse_datetime(str(expires_at)) + if not dt: + return Response({'error': 'Invalid datetime. Use ISO 8601.'}, status=status.HTTP_400_BAD_REQUEST) + record.expires_at = dt + else: + record.expires_at = None + record.save(update_fields=['expires_at', 'updated_at']) + return Response(self.get_serializer(record).data) + + @action(detail=True, methods=['get']) + def download(self, request, pk=None): + record = self.get_object() + if not os.path.exists(record.file_path): + raise Http404("File not found") + FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1) + disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"' + response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type) + response['Content-Disposition'] = disposition + return response + + +def import_image_view(request, image_url): + """Proxy-and-cache an external image via /import/images/. + + The image_url path component has no scheme (e.g. 'example.com/path/img.jpg'). + On first request the view: + 1. Creates a FileUpload stub in the database (is_public=True, source_url set). + 2. Fires a background thread to download and save the file. + 3. Immediately redirects to the original URL so the image is visible right away. + On subsequent requests, once the file is saved locally, the view redirects to + the stored public URL instead — no more dependency on the original host. + + If the background download fails, the stub record is deleted automatically so no + size=0 ghost appears in the file list. The next visit to the same URL will create + a fresh stub and retry. A periodic APScheduler task (`retry_stuck_image_imports`) + also reschedules any stubs left behind by killed threads. + """ + import hashlib + import posixpath + from threading import Thread + from .tasks import download_and_save_image + + # Build the canonical source URL, preserving query string. + # Django's converter only captures the path component; query params + # like ?format=jpg&name=900x900 end up in QUERY_STRING and must be re-attached. + query_string = request.META.get('QUERY_STRING', '') + source_url = f'https://{image_url}' + if query_string: + source_url = f'{source_url}?{query_string}' + + url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:20] + # Strip query string for filename derivation + filename = posixpath.basename(image_url.split('?')[0]) or f'image_{url_hash}' + + # Derive extension from filename; fall back to .jpg for bare names + _, ext = posixpath.splitext(filename) + if not ext: + ext = '.jpg' + filename = f'{filename}{ext}' + + stored_name = f'import_{url_hash}{ext}' + + # Try to find an existing record for this URL (idempotent) + record = FileUpload.objects.filter(source_url=source_url).first() + + if record is None: + # Create the stub immediately so we have a stable public URL + record = FileUpload.objects.create( + name=filename, + stored_name=stored_name, + mime_type=f'image/{ext.lstrip(".") or "jpeg"}', + size=0, + is_public=True, + source_url=source_url, + ) + logger.info(f"import_image_view: created FileUpload {record.pk} for {source_url}") + + # If the file is already on disk, serve from local storage + if os.path.exists(record.file_path): + return redirect(record.public_url) + + # File not yet saved — kick off (or re-kick) the background download + thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True) + thread.start() + + # Redirect to the original URL as a temporary placeholder while download runs + return redirect(source_url) diff --git a/links/forms.py b/links/forms.py index 6406f34..ddd969f 100644 --- a/links/forms.py +++ b/links/forms.py @@ -3,6 +3,7 @@ from .models import Link, Page, Post, ImageCollection, Image, Tag from simplemde.fields import SimpleMDEField from django.utils.translation import gettext_lazy as _ from django.core.validators import URLValidator +from urllib.parse import quote import re class LinkForm(forms.ModelForm): @@ -32,8 +33,13 @@ class LinkForm(forms.ModelForm): temp_url = temp_url.replace(param_full, 'template-param') # Validate the URL with placeholders + # Encode non-ASCII characters and spaces so that static Unicode text + # (e.g. Chinese characters) mixed with template params passes URLValidator. + # The safe string keeps all standard URL characters intact and includes '%' + # to avoid double-encoding any already percent-encoded sequences. try: - URLValidator()(temp_url) + encoded_url = quote(temp_url, safe="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%") + URLValidator()(encoded_url) except forms.ValidationError: raise forms.ValidationError(_("Please enter a valid URL. Template parameters are allowed in the format {param_name,default=value}.")) diff --git a/links/image_api.py b/links/image_api.py index 9ac7a51..e58c4e9 100644 --- a/links/image_api.py +++ b/links/image_api.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) FitMode = Literal['clip', 'crop', 'fill', 'scale'] # Configuration constants -IMAGES_FOLDER = os.getenv('IMAGES_FOLDER', '/Users/junv/Downloads') +IMAGES_FOLDER = settings.IMAGES_FOLDER CACHE_TIMEOUT = 600 # 10 minutes for HTTP caching MAX_FILE_SIZE = 3 * 1024 * 1024 # 3MB in bytes SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'} diff --git a/links/job_registry.py b/links/job_registry.py new file mode 100644 index 0000000..a4a0349 --- /dev/null +++ b/links/job_registry.py @@ -0,0 +1,50 @@ +""" +Job type registry for the /ui/jobs/ page. + +To add a new job tab: + 1. Call ``register(job_type_dict)`` from ``LinksConfig.ready()`` in links/apps.py. + 2. Add the corresponding bulk action handler in ``JobsView.post()``. + +Each job type dict must have: + id (str) unique tab slug, used as ?tab= value + label (str) display name shown in the tab bar + icon_color (str) Tailwind text-colour class, e.g. 'text-indigo-500' + icon_path (str) SVG value for the stats card icon + title_label (str) column header for the title/name column + status_choices (list) [(value, label), ...]; first entry should be ('all', 'All') + columns (list[str]) ordered column ids from: + ['id','title','source_url','status','retry','error','size','updated'] + get_stats (callable) () -> dict with 'total' plus any of: + pending / processing / completed / failed + (omit keys that do not apply to this type) + get_queryset (callable) (status_filter: str) -> QuerySet + serialize (callable) (obj) -> dict with keys: + id, title, detail_url, status, + retry (int|None), retry_max (int|None), + error (str), updated_at (datetime), + extra (dict – for source_url, formatted_size, etc.) + bulk_actions (dict) subset of {'retry': action_name, + 'fail': action_name, + 'delete': action_name} +""" +import logging + +logger = logging.getLogger(__name__) + +_registry: list[dict] = [] + + +def register(job_type: dict) -> None: + """Register a job type. Call from LinksConfig.ready().""" + _registry.append(job_type) + logger.debug("Registered job type: %s", job_type.get("id")) + + +def all_types() -> list[dict]: + """Return all registered job types in registration order.""" + return list(_registry) + + +def get_type(type_id: str) -> dict | None: + """Return the job type dict with the given id, or None.""" + return next((j for j in _registry if j["id"] == type_id), None) diff --git a/links/migrations/0039_file_upload.py b/links/migrations/0039_file_upload.py new file mode 100644 index 0000000..d896898 --- /dev/null +++ b/links/migrations/0039_file_upload.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.11 on 2026-03-21 06:18 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0038_remove_iptv_models'), + ] + + operations = [ + migrations.CreateModel( + name='FileUpload', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('stored_name', models.CharField(max_length=255, verbose_name='Stored Name')), + ('mime_type', models.CharField(blank=True, max_length=128, verbose_name='MIME Type')), + ('size', models.PositiveBigIntegerField(default=0, verbose_name='Size')), + ('is_public', models.BooleanField(db_index=True, default=False, verbose_name='Is Public')), + ('public_token', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='Public Token')), + ('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Expires At')), + ('download_count', models.PositiveIntegerField(default=0, verbose_name='Download Count')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ], + options={ + 'verbose_name': 'File Upload', + 'verbose_name_plural': 'File Uploads', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/links/migrations/0040_site_settings.py b/links/migrations/0040_site_settings.py new file mode 100644 index 0000000..a88124a --- /dev/null +++ b/links/migrations/0040_site_settings.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-03-21 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0039_file_upload'), + ] + + operations = [ + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public_sharing_domain', models.CharField(blank=True, default='', help_text='Domain (with protocol) used for public sharing links, e.g. https://go.example.com. Leave blank to use the same domain as the app.', max_length=255, verbose_name='Public Sharing Domain')), + ], + options={ + 'verbose_name': 'Site Settings', + }, + ), + ] diff --git a/links/migrations/0041_screenshot_retry_count_sitesettings_max_concurrent.py b/links/migrations/0041_screenshot_retry_count_sitesettings_max_concurrent.py new file mode 100644 index 0000000..929b1a6 --- /dev/null +++ b/links/migrations/0041_screenshot_retry_count_sitesettings_max_concurrent.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0040_site_settings'), + ] + + operations = [ + migrations.AddField( + model_name='screenshot', + name='retry_count', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='sitesettings', + name='max_concurrent_screenshot_jobs', + field=models.IntegerField( + default=2, + help_text='Maximum number of Chromium screenshot processes to run simultaneously. Lower this value if the container runs out of memory.', + verbose_name='Max Concurrent Screenshot Jobs', + ), + ), + ] diff --git a/links/migrations/0042_sitesettings_scheduler_intervals.py b/links/migrations/0042_sitesettings_scheduler_intervals.py new file mode 100644 index 0000000..3a2bc20 --- /dev/null +++ b/links/migrations/0042_sitesettings_scheduler_intervals.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-03-21 11:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0041_screenshot_retry_count_sitesettings_max_concurrent'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='schedule_pending_pages_interval', + field=models.IntegerField(default=120, help_text='How often (in seconds) to check for pending pages and queue them for processing.', verbose_name='Schedule Pending Pages Interval (seconds)'), + ), + migrations.AddField( + model_name='sitesettings', + name='schedule_pending_screenshots_interval', + field=models.IntegerField(default=120, help_text='How often (in seconds) to check for stuck or pending screenshots and retry them.', verbose_name='Schedule Pending Screenshots Interval (seconds)'), + ), + ] diff --git a/links/migrations/0043_add_screenshot_job_timeout_to_sitesettings.py b/links/migrations/0043_add_screenshot_job_timeout_to_sitesettings.py new file mode 100644 index 0000000..2a28a91 --- /dev/null +++ b/links/migrations/0043_add_screenshot_job_timeout_to_sitesettings.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-22 03:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0042_sitesettings_scheduler_intervals'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='screenshot_job_timeout_seconds', + field=models.IntegerField(default=300, help_text='Maximum time in seconds a single screenshot job may run before it is cancelled and marked as failed. Default is 300 (5 minutes).', verbose_name='Screenshot Job Timeout (seconds)'), + ), + ] diff --git a/links/migrations/0044_add_source_url_to_fileupload.py b/links/migrations/0044_add_source_url_to_fileupload.py new file mode 100644 index 0000000..ffe2275 --- /dev/null +++ b/links/migrations/0044_add_source_url_to_fileupload.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-22 03:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0043_add_screenshot_job_timeout_to_sitesettings'), + ] + + operations = [ + migrations.AddField( + model_name='fileupload', + name='source_url', + field=models.URLField(blank=True, db_index=True, max_length=2048, null=True, verbose_name='Source URL'), + ), + ] diff --git a/links/migrations/0045_knowledge_graph.py b/links/migrations/0045_knowledge_graph.py new file mode 100644 index 0000000..d815871 --- /dev/null +++ b/links/migrations/0045_knowledge_graph.py @@ -0,0 +1,88 @@ +# Generated by Django 5.2.12 on 2026-03-30 06:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0044_add_source_url_to_fileupload'), + ] + + operations = [ + migrations.CreateModel( + name='KnowledgeGraphSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('building', 'Building'), ('ready', 'Ready'), ('failed', 'Failed')], db_index=True, default='building', max_length=20, verbose_name='Status')), + ('graph_data', models.JSONField(default=dict, help_text='Graphology-compatible serialization: {nodes: [...], edges: [...]}', verbose_name='Graph Data')), + ('node_count', models.IntegerField(default=0, verbose_name='Node Count')), + ('edge_count', models.IntegerField(default=0, verbose_name='Edge Count')), + ('used_llm', models.BooleanField(default=False, verbose_name='Used LLM')), + ('error_message', models.TextField(blank=True, verbose_name='Error Message')), + ('build_duration_ms', models.IntegerField(default=0, verbose_name='Build Duration (ms)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Completed At')), + ], + options={ + 'verbose_name': 'Knowledge Graph Snapshot', + 'verbose_name_plural': 'Knowledge Graph Snapshots', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='sitesettings', + name='kg_auto_schedule_enabled', + field=models.BooleanField(default=False, help_text='When enabled, the knowledge graph is rebuilt on the configured interval.', verbose_name='Auto-rebuild Knowledge Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_auto_schedule_interval', + field=models.IntegerField(default=3600, help_text='How often (in seconds) to auto-rebuild the knowledge graph. Minimum 60.', verbose_name='Knowledge Graph Rebuild Interval (seconds)'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_links', + field=models.BooleanField(default=True, verbose_name='Include Links in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_pages', + field=models.BooleanField(default=True, verbose_name='Include Pages in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_posts', + field=models.BooleanField(default=True, verbose_name='Include Posts in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_include_tags', + field=models.BooleanField(default=True, verbose_name='Include Tags in Graph'), + ), + migrations.AddField( + model_name='sitesettings', + name='kg_semantic_threshold', + field=models.FloatField(default=0.7, help_text='Minimum cosine similarity (0.0–1.0) required to draw a semantic edge between two items.', verbose_name='Semantic Similarity Threshold'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_api_key', + field=models.CharField(blank=True, default='', help_text='API key for OpenRouter (not needed for Ollama).', max_length=255, verbose_name='LLM API Key'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_base_url', + field=models.CharField(blank=True, default='http://localhost:11434', help_text='Base URL for the Ollama server (e.g. http://192.168.1.2:11434).', max_length=255, verbose_name='LLM Base URL'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_model', + field=models.CharField(blank=True, default='nomic-embed-text', help_text='Model name used for embeddings (e.g. nomic-embed-text for Ollama).', max_length=100, verbose_name='LLM Embedding Model'), + ), + migrations.AddField( + model_name='sitesettings', + name='llm_provider', + field=models.CharField(choices=[('none', 'None (no LLM)'), ('ollama', 'Ollama (local)'), ('openrouter', 'OpenRouter')], default='none', help_text='LLM/embedding provider used to generate semantic edges in the knowledge graph.', max_length=20, verbose_name='LLM Provider'), + ), + ] diff --git a/links/migrations/0046_kg_progress_data.py b/links/migrations/0046_kg_progress_data.py new file mode 100644 index 0000000..cca3948 --- /dev/null +++ b/links/migrations/0046_kg_progress_data.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-30 07:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0045_knowledge_graph'), + ] + + operations = [ + migrations.AddField( + model_name='knowledgegraphsnapshot', + name='progress_data', + field=models.JSONField(blank=True, default=dict, help_text='Live build progress: {pct, step, logs}', verbose_name='Progress Data'), + ), + ] diff --git a/links/migrations/0047_add_is_public_to_post.py b/links/migrations/0047_add_is_public_to_post.py new file mode 100644 index 0000000..667819a --- /dev/null +++ b/links/migrations/0047_add_is_public_to_post.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-30 09:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0046_kg_progress_data'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='is_public', + field=models.BooleanField(db_index=True, default=False, verbose_name='Is Public'), + ), + ] diff --git a/links/migrations/0048_remove_knowledge_graph.py b/links/migrations/0048_remove_knowledge_graph.py new file mode 100644 index 0000000..caf2d13 --- /dev/null +++ b/links/migrations/0048_remove_knowledge_graph.py @@ -0,0 +1,58 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0047_add_is_public_to_post'), + ] + + operations = [ + migrations.DeleteModel( + name='KnowledgeGraphSnapshot', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_provider', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_base_url', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_model', + ), + migrations.RemoveField( + model_name='sitesettings', + name='llm_api_key', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_auto_schedule_enabled', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_auto_schedule_interval', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_semantic_threshold', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_links', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_pages', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_posts', + ), + migrations.RemoveField( + model_name='sitesettings', + name='kg_include_tags', + ), + ] diff --git a/links/mini_apps_views.py b/links/mini_apps_views.py index 0dc5128..594fe86 100644 --- a/links/mini_apps_views.py +++ b/links/mini_apps_views.py @@ -1,5 +1,11 @@ from django.views.generic import TemplateView from django.shortcuts import render +from django.http import JsonResponse +from django.views import View +import json +import logging + +logger = logging.getLogger(__name__) class MiniAppsListView(TemplateView): @@ -34,6 +40,14 @@ class MiniAppsListView(TemplateView): 'icon': 'fas fa-chart-line', 'color': '#27ae60' }, + { + 'name': 'Nginx IP Ban', + 'description': 'Manage the nginx ingress block list — view, add, and remove banned IPs and CIDR ranges directly from the Kubernetes ConfigMap.', + 'url': 'mini-apps-ip-ban', + 'thumbnail': 'https://images.unsplash.com/photo-1614064641938-3bbee52942c7?w=400&h=300&fit=crop', + 'icon': 'fas fa-ban', + 'color': '#e74c3c' + }, { 'name': 'Weather Dashboard', 'description': 'Real-time weather information with beautiful visualizations and forecasts.', @@ -41,7 +55,7 @@ class MiniAppsListView(TemplateView): 'thumbnail': 'https://images.unsplash.com/photo-1504608524841-42fe6f032b4b?w=400&h=300&fit=crop', 'icon': 'fas fa-cloud-sun', 'color': '#e74c3c' - } + }, ] context['mini_apps'] = mini_apps @@ -73,3 +87,84 @@ class FIREPlanningView(TemplateView): context = super().get_context_data(**kwargs) context['page_title'] = 'FIRE Planning Calculator' return context + + +# --------------------------------------------------------------------------- +# Nginx IP Ban mini app +# --------------------------------------------------------------------------- + +_CONFIGMAP_NAME = 'ingress-nginx-controller' +_CONFIGMAP_NS = 'ingress-nginx' +_CONFIGMAP_KEY = 'block-cidrs-manual' + + +def _k8s_v1(): + """Return a CoreV1Api client, preferring in-cluster then local kubeconfig.""" + from kubernetes import client, config + try: + config.load_incluster_config() + except config.ConfigException: + config.load_kube_config() + return client.CoreV1Api() + + +def _read_blocked_ips() -> list[str]: + v1 = _k8s_v1() + cm = v1.read_namespaced_config_map(_CONFIGMAP_NAME, _CONFIGMAP_NS) + raw = (cm.data or {}).get(_CONFIGMAP_KEY, '') + return [ip.strip() for ip in raw.split(',') if ip.strip()] + + +def _write_blocked_ips(ips: list[str]) -> None: + v1 = _k8s_v1() + from kubernetes.client import V1ConfigMap + body = V1ConfigMap(data={_CONFIGMAP_KEY: ','.join(ips)}) + v1.patch_namespaced_config_map(_CONFIGMAP_NAME, _CONFIGMAP_NS, body) + + +class NginxIPBanView(View): + template = 'links/mini_apps/ip_ban.html' + + def get(self, request): + error = None + blocked = [] + try: + blocked = _read_blocked_ips() + except Exception as exc: + logger.error('ip_ban: failed to read ConfigMap: %s', exc) + error = str(exc) + return render(request, self.template, {'blocked': blocked, 'error': error}) + + def post(self, request): + action = request.POST.get('action') + try: + blocked = _read_blocked_ips() + if action == 'add': + raw = request.POST.get('ips', '') + # Accept comma- or newline-separated entries + new_ips = [ + ip.strip() + for part in raw.replace('\n', ',').split(',') + for ip in [part.strip()] + if ip + ] + added = 0 + for ip in new_ips: + if ip not in blocked: + blocked.append(ip) + added += 1 + _write_blocked_ips(blocked) + return JsonResponse({'ok': True, 'blocked': blocked, 'added': added}) + + elif action == 'remove': + ip = request.POST.get('ip', '').strip() + if ip in blocked: + blocked.remove(ip) + _write_blocked_ips(blocked) + return JsonResponse({'ok': True, 'blocked': blocked}) + + return JsonResponse({'ok': False, 'error': 'Unknown action'}, status=400) + + except Exception as exc: + logger.error('ip_ban: action=%s error=%s', action, exc) + return JsonResponse({'ok': False, 'error': str(exc)}, status=500) diff --git a/links/models.py b/links/models.py index 8f805a4..f2b6a7f 100644 --- a/links/models.py +++ b/links/models.py @@ -256,6 +256,7 @@ class Screenshot(models.Model): path = models.CharField(max_length=255, blank=True, null=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) error = models.TextField(blank=True, null=True) + retry_count = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -271,6 +272,7 @@ class Post(models.Model): title = models.CharField(_('Title'), max_length=200) summary = models.TextField(_('Summary'), blank=True, help_text=_('A brief summary of the post')) content = models.TextField(_('Content')) + is_public = models.BooleanField(_('Is Public'), default=False, db_index=True) created_at = models.DateTimeField(_('Created at'), auto_now_add=True) updated_at = models.DateTimeField(_('Updated at'), auto_now=True) tags = models.ManyToManyField('Tag', blank=True, related_name='posts') @@ -338,3 +340,131 @@ class Image(models.Model): height=height, fit='cover' ) + + +class FileUpload(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(_('Name'), max_length=255) + stored_name = models.CharField(_('Stored Name'), max_length=255) + mime_type = models.CharField(_('MIME Type'), max_length=128, blank=True) + size = models.PositiveBigIntegerField(_('Size'), default=0) + is_public = models.BooleanField(_('Is Public'), default=False, db_index=True) + public_token = models.CharField(_('Public Token'), max_length=64, unique=True, null=True, blank=True) + expires_at = models.DateTimeField(_('Expires At'), null=True, blank=True) + source_url = models.URLField(_('Source URL'), max_length=2048, null=True, blank=True, db_index=True) + download_count = models.PositiveIntegerField(_('Download Count'), default=0) + created_at = models.DateTimeField(_('Created At'), auto_now_add=True) + updated_at = models.DateTimeField(_('Updated At'), auto_now=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = _('File Upload') + verbose_name_plural = _('File Uploads') + + def __str__(self): + return self.name + + @property + def file_path(self): + return os.path.join(settings.FILE_UPLOADS_FOLDER, self.stored_name) + + @property + def is_expired(self): + if self.expires_at: + return timezone.now() > self.expires_at + return False + + @property + def is_image(self): + return self.mime_type.startswith('image/') + + @property + def is_publicly_accessible(self): + return self.is_public and not self.is_expired + + @property + def download_url(self): + import re + safe_name = re.sub(r'[^\w.\-]', '-', self.name) + return f'/ui/files/{self.pk}-{safe_name}' + + @property + def public_url(self): + import re as _re + safe_name = _re.sub(r'[^\w.\-]', '-', self.name) + path = f'/public/files/{self.pk}-{safe_name}' + if not self.is_public: + return path + try: + domain = SiteSettings.get().public_sharing_domain.rstrip('/') + except Exception: + domain = '' + return f'{domain}{path}' if domain else path + + def formatted_size(self): + if self.size < 1024: + return f"{self.size} B" + elif self.size < 1024 * 1024: + return f"{self.size / 1024:.1f} KB" + elif self.size < 1024 * 1024 * 1024: + return f"{self.size / (1024 * 1024):.1f} MB" + return f"{self.size / (1024 * 1024 * 1024):.2f} GB" + + +class SiteSettings(models.Model): + """ + Singleton model for app-wide configuration. + Always use SiteSettings.get() to retrieve the instance. + """ + + public_sharing_domain = models.CharField( + _('Public Sharing Domain'), + max_length=255, + blank=True, + default='', + help_text=_( + 'Domain (with protocol) used for public sharing links, e.g. https://go.example.com. ' + 'Leave blank to use the same domain as the app.' + ), + ) + max_concurrent_screenshot_jobs = models.IntegerField( + _('Max Concurrent Screenshot Jobs'), + default=2, + help_text=_( + 'Maximum number of Chromium screenshot processes to run simultaneously. ' + 'Lower this value if the container runs out of memory.' + ), + ) + schedule_pending_pages_interval = models.IntegerField( + _('Schedule Pending Pages Interval (seconds)'), + default=120, + help_text=_('How often (in seconds) to check for pending pages and queue them for processing.'), + ) + schedule_pending_screenshots_interval = models.IntegerField( + _('Schedule Pending Screenshots Interval (seconds)'), + default=120, + help_text=_('How often (in seconds) to check for stuck or pending screenshots and retry them.'), + ) + screenshot_job_timeout_seconds = models.IntegerField( + _('Screenshot Job Timeout (seconds)'), + default=300, + help_text=_( + 'Maximum time in seconds a single screenshot job may run before it is cancelled and ' + 'marked as failed. Default is 300 (5 minutes).' + ), + ) + + class Meta: + verbose_name = _('Site Settings') + + def __str__(self): + return 'Site Settings' + + @classmethod + def get(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) diff --git a/links/page_views.py b/links/page_views.py index 27b08aa..e7af0e9 100644 --- a/links/page_views.py +++ b/links/page_views.py @@ -6,6 +6,7 @@ from django.contrib import messages from django.utils.translation import gettext_lazy as _ from django.core.files.base import ContentFile from django.conf import settings +from django.core.cache import cache from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response @@ -67,6 +68,11 @@ class PageListView(ListView): context_object_name = 'pages' paginate_by = 10 + def get_template_names(self): + if self.request.headers.get('HX-Request'): + return ['links/includes/page_list_items.html'] + return [self.template_name] + class PageDetailView(DetailView): model = Page template_name = 'links/page_detail.html' @@ -142,6 +148,13 @@ def fetch_page_info(request): url = url.lstrip('@') + # Return cached metadata if available (24-hour TTL) + import hashlib as _hashlib + cache_key = 'pageinfo:' + _hashlib.md5(url.encode()).hexdigest() + cached = cache.get(cache_key) + if cached is not None: + return JsonResponse(cached) + try: 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', @@ -193,10 +206,9 @@ def fetch_page_info(request): description = re.sub(r'\s+', ' ', description.strip()) description = description[:500] + '...' if len(description) > 500 else description - return JsonResponse({ - 'title': title, - 'summary': description - }) + result = {'title': title, 'summary': description} + cache.set(cache_key, result, timeout=86400) + return JsonResponse(result) except Exception as e: return JsonResponse({ diff --git a/links/post_views.py b/links/post_views.py index 5ee6d20..469ed5a 100644 --- a/links/post_views.py +++ b/links/post_views.py @@ -1,12 +1,15 @@ -from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView -from django.urls import reverse_lazy +import json + +from django.http import Http404, JsonResponse +from django.urls import reverse, reverse_lazy +from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.http import Http404 +from django.views import View +from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.pagination import PageNumberPagination -from django.utils import timezone from datetime import datetime from .models import Post from .templatetags.tasklist_markdown import update_task_in_markdown @@ -20,6 +23,11 @@ class PostListView(ListView): context_object_name = 'posts' paginate_by = 10 + def get_template_names(self): + if self.request.headers.get('HX-Request'): + return ['links/includes/post_list_items.html'] + return [self.template_name] + def get_queryset(self): queryset = Post.objects.all().order_by('-created_at') @@ -71,7 +79,9 @@ class PostUpdateView(UpdateView): model = Post form_class = PostForm template_name = 'links/post_form.html' - success_url = reverse_lazy('post-list') + + def get_success_url(self): + return reverse_lazy('post-detail', kwargs={'pk': self.object.pk}) def dispatch(self, request, *args, **kwargs): response = super().dispatch(request, *args, **kwargs) @@ -98,10 +108,35 @@ class PublicPostView(DetailView): return context def get_object(self, queryset=None): - try: - return super().get_object(queryset) - except Http404: + obj = super().get_object(queryset) + if not obj.is_public: raise Http404("Post not found") + return obj + + +class PostShareView(View): + """Toggle public sharing for a post. POST body: {"enable": true|false}""" + + def post(self, request, pk): + try: + post = Post.objects.get(pk=pk) + except Post.DoesNotExist: + return JsonResponse({'error': 'Not found'}, status=404) + + try: + body = json.loads(request.body or '{}') + enable = bool(body.get('enable', True)) + except (json.JSONDecodeError, AttributeError): + enable = True + + post.is_public = enable + post.save(update_fields=['is_public']) + + public_url = ( + request.build_absolute_uri(reverse('public-post', args=[post.pk])) + if post.is_public else None + ) + return JsonResponse({'is_public': post.is_public, 'url': public_url}) class StandardResultsSetPagination(PageNumberPagination): page_size = 10 @@ -131,6 +166,25 @@ class PostViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=True, methods=['post']) + def append_content(self, request, pk=None): + """ + Append markdown text to a post's existing content. + + POST /api/posts/{id}/append_content/ + Body: {"content": "## New section\n\nSome text"} + """ + post = self.get_object() + extra = request.data.get('content', '').strip() + + if not extra: + return Response({'error': 'content is required'}, status=status.HTTP_400_BAD_REQUEST) + + post.content = post.content + '\n\n' + extra + post.save(update_fields=['content', 'updated_at']) + + return Response({'status': 'ok'}) + @action(detail=True, methods=['post']) def toggle_task(self, request, pk=None): """ diff --git a/links/search_views.py b/links/search_views.py index 13c06fe..1a18c9f 100644 --- a/links/search_views.py +++ b/links/search_views.py @@ -10,6 +10,8 @@ from .models import Link, Page, Post from .search_backend import search_backend from django.views.decorators.http import require_http_methods from django.views.decorators.csrf import csrf_exempt +from django.core.cache import cache +import hashlib import json import requests import os @@ -62,7 +64,7 @@ def search_vector_api(request): 'score': hit.score, 'tags': p.get('tags', []) }) - + return JsonResponse({'results': results, 'total': len(results)}) except Exception as e: logger.error(f"Vector search error: {e}") @@ -154,6 +156,14 @@ def search_api_v2(request): per_page = int(request.GET.get('per_page', 20)) if not query: return JsonResponse({'results': [], 'total': 0, 'page': page, 'per_page': per_page, 'has_next': False, 'has_prev': False}) + + cache_key = 'search:v2:' + hashlib.md5( + f'{query}:{type_filter}:{sort_by}:{page}:{per_page}'.encode() + ).hexdigest() + cached = cache.get(cache_key) + if cached is not None: + return JsonResponse(cached) + try: search_results = search_backend.search(query_string=query, model_type=type_filter if type_filter else None, page=page, per_page=per_page, sort_by=sort_by) enriched_results = [] @@ -173,7 +183,9 @@ def search_api_v2(request): except Exception as e: logger.error(f"Error enriching {model_type} {model_id}: {e}") continue - return JsonResponse({'results': enriched_results, 'total': search_results['total'], 'page': search_results['page'], 'per_page': search_results['per_page'], 'has_next': search_results['has_next'], 'has_prev': search_results['has_prev']}) + result_payload = {'results': enriched_results, 'total': search_results['total'], 'page': search_results['page'], 'per_page': search_results['per_page'], 'has_next': search_results['has_next'], 'has_prev': search_results['has_prev']} + cache.set(cache_key, result_payload, timeout=300) + return JsonResponse(result_payload) except Exception as e: logger.error(f"Search API error: {e}", exc_info=True) return JsonResponse({'error': str(e), 'results': [], 'total': 0}, status=500) diff --git a/links/serializers.py b/links/serializers.py index 59c45e1..7bf48b7 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -1,5 +1,18 @@ from rest_framework import serializers -from .models import Page, Post, ImageCollection, Image, Tag +from .models import Link, Page, Post, ImageCollection, Image, Tag, FileUpload + + +class LinkSerializer(serializers.ModelSerializer): + tags = serializers.SerializerMethodField() + + class Meta: + model = Link + fields = ['id', 'alias', 'original_url', 'description', 'link_type', + 'click_count', 'tags', 'created_at', 'updated_at'] + read_only_fields = ['id', 'click_count', 'created_at', 'updated_at'] + + def get_tags(self, obj): + return [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in obj.tags.all()] class PageSerializer(serializers.ModelSerializer): class Meta: @@ -12,24 +25,24 @@ class PageSerializer(serializers.ModelSerializer): class PostSerializer(serializers.ModelSerializer): tags = serializers.ListField(child=serializers.CharField(), required=False, write_only=True, help_text="List of tag slugs") tag_details = serializers.SerializerMethodField(read_only=True) - + class Meta: model = Post - fields = ['id', 'title', 'summary', 'content', 'tags', 'tag_details', 'created_at', 'updated_at'] + fields = ['id', 'title', 'summary', 'content', 'is_public', 'tags', 'tag_details', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at'] extra_kwargs = { 'title': {'required': True}, 'content': {'required': True}, 'summary': {'required': False} } - + def get_tag_details(self, obj): return [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in obj.tags.all()] def create(self, validated_data): tag_slugs = validated_data.pop('tags', []) post = Post.objects.create(**validated_data) - + for tag_slug in tag_slugs: try: tag = Tag.objects.get(slug=tag_slug) @@ -37,14 +50,14 @@ class PostSerializer(serializers.ModelSerializer): # If tag doesn't exist, create it with the slug as both name and slug tag = Tag.objects.create(name=tag_slug, slug=tag_slug) post.tags.add(tag) - + return post def update(self, instance, validated_data): tag_slugs = validated_data.pop('tags', None) for attr, value in validated_data.items(): setattr(instance, attr, value) - + if tag_slugs is not None: instance.tags.clear() for tag_slug in tag_slugs: @@ -54,7 +67,7 @@ class PostSerializer(serializers.ModelSerializer): # If tag doesn't exist, create it with the slug as both name and slug tag = Tag.objects.create(name=tag_slug, slug=tag_slug) instance.tags.add(tag) - + instance.save() return instance @@ -83,3 +96,31 @@ class ImageCollectionSerializer(serializers.ModelSerializer): def get_image_count(self, obj): return obj.images.count() + +class FileUploadSerializer(serializers.ModelSerializer): + formatted_size = serializers.SerializerMethodField() + public_url = serializers.SerializerMethodField() + is_expired = serializers.SerializerMethodField() + + class Meta: + model = FileUpload + fields = [ + 'id', 'name', 'mime_type', 'size', 'formatted_size', + 'is_public', 'public_token', 'public_url', + 'expires_at', 'is_expired', + 'download_count', 'created_at', 'updated_at', + ] + read_only_fields = [ + 'id', 'mime_type', 'size', 'formatted_size', + 'public_token', 'public_url', 'is_expired', + 'download_count', 'created_at', 'updated_at', + ] + + def get_formatted_size(self, obj): + return obj.formatted_size() + + def get_public_url(self, obj): + return obj.public_url + + def get_is_expired(self, obj): + return obj.is_expired diff --git a/links/tasks.py b/links/tasks.py index f02a726..1bfd6b9 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -1,4 +1,5 @@ from django.utils import timezone +from django.db.models import F from datetime import timedelta import requests from bs4 import BeautifulSoup @@ -54,60 +55,82 @@ def extract_description_from_soup(soup): return text return None -async def _capture_screenshot_async(page_url, full_path): - """Async function to capture screenshot using Playwright""" - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, - args=[ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - ] - ) - - context = await browser.new_context( - viewport={'width': 1366, 'height': 768}, - locale='zh-CN', - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - extra_http_headers={ - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - }, - ignore_https_errors=True, - ) - - page = await context.new_page() - - try: - # Navigate and wait for network idle - await page.goto( - page_url, - wait_until='networkidle', - timeout=SCREENSHOT_TIMEOUT +async def _capture_screenshot_async(page_url, full_path, job_timeout_seconds=300): + """Async function to capture screenshot using Playwright. + + Raises asyncio.TimeoutError if the entire operation exceeds job_timeout_seconds. + """ + async def _run(): + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=[ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + ] ) - - # Take full-page screenshot - await page.screenshot( - path=full_path, - full_page=True, - type='png' + + context = await browser.new_context( + viewport={'width': 1366, 'height': 768}, + locale='zh-CN', + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + extra_http_headers={ + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + ignore_https_errors=True, ) - - logger.info(f"Successfully captured screenshot: {full_path}") - - finally: - await context.close() - await browser.close() + + page = await context.new_page() + + try: + await page.goto( + page_url, + wait_until='networkidle', + timeout=SCREENSHOT_TIMEOUT + ) + + await page.screenshot( + path=full_path, + full_page=True, + type='png' + ) + + logger.info(f"Successfully captured screenshot: {full_path}") + + finally: + await context.close() + await browser.close() + + await asyncio.wait_for(_run(), timeout=job_timeout_seconds) def capture_screenshot(page_id, screenshot_id, retry_count=0): """Task to capture full-page screenshot with Playwright""" - from .models import Page, Screenshot + from .models import Page, Screenshot, SiteSettings + screenshot = None try: page = Page.objects.get(id=page_id) screenshot = Screenshot.objects.get(id=screenshot_id) + + # Idempotency: skip if already completed + if screenshot.status == Screenshot.Status.COMPLETED: + logger.info(f"Screenshot {screenshot_id} already completed, skipping") + return + + # Respect model-level retry limit (model tracks total attempts across restarts) + if screenshot.retry_count >= MAX_RETRIES: + logger.warning(f"Screenshot {screenshot_id} exceeded max retries ({MAX_RETRIES}), marking FAILED") + screenshot.status = Screenshot.Status.FAILED + screenshot.error = f"Exceeded maximum retries ({MAX_RETRIES})" + screenshot.save() + return + + job_timeout = SiteSettings.get().screenshot_job_timeout_seconds + screenshot.status = Screenshot.Status.PROCESSING + screenshot.error = None screenshot.save() # Create screenshots directory @@ -122,15 +145,22 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): # Run async screenshot capture in sync context try: - asyncio.run(_capture_screenshot_async(page.url, full_path)) - + asyncio.run(_capture_screenshot_async(page.url, full_path, job_timeout_seconds=job_timeout)) + # Update screenshot record screenshot.path = filepath screenshot.status = Screenshot.Status.COMPLETED screenshot.save() - + logger.info(f"Successfully captured screenshot for page {page_id}") - + + except asyncio.TimeoutError: + msg = f"Job timed out after {job_timeout}s" + logger.error(f"Screenshot {screenshot_id}: {msg}") + screenshot.status = Screenshot.Status.FAILED + screenshot.error = msg + screenshot.save() + return except PlaywrightTimeoutError as e: logger.error(f"Timeout during screenshot capture: {str(e)}") screenshot.status = Screenshot.Status.FAILED @@ -145,35 +175,55 @@ def capture_screenshot(page_id, screenshot_id, retry_count=0): raise except Exception as exc: - logger.error(f"Failed to capture screenshot for page {page_id}: {exc}") - if retry_count < MAX_RETRIES: - retry_delay = fibonacci(retry_count) + logger.error(f"Failed to capture screenshot for page {page_id}: {exc}", exc_info=True) + if screenshot is None: + return + + # Increment model-level retry count + try: + screenshot.retry_count = Screenshot.objects.filter(pk=screenshot.pk).values_list('retry_count', flat=True).first() or 0 + screenshot.retry_count += 1 + screenshot.save(update_fields=['retry_count', 'error', 'updated_at']) + except Exception: + pass + + if screenshot.retry_count >= MAX_RETRIES: + logger.error(f"Max retries exceeded for screenshot {screenshot_id}") try: - screenshot.error = f"Error: {str(exc)}. Retrying in {retry_delay} seconds..." + screenshot.status = Screenshot.Status.FAILED + screenshot.error = f"Failed after {screenshot.retry_count} attempts: {str(exc)}" screenshot.save() - except: + except Exception: + pass + else: + retry_delay = fibonacci(screenshot.retry_count) + try: + screenshot.error = f"Retrying in {retry_delay}s (attempt {screenshot.retry_count}/{MAX_RETRIES}): {str(exc)}" + screenshot.save(update_fields=['error', 'updated_at']) + except Exception: pass # Schedule retry using APScheduler from core.scheduler import scheduler from datetime import datetime run_date = datetime.now() + timedelta(seconds=retry_delay) - scheduler.add_job( - capture_screenshot, - 'date', - run_date=run_date, - args=[page_id, screenshot_id, retry_count + 1], - id=f'capture_screenshot_{screenshot_id}_retry_{retry_count + 1}', - replace_existing=True - ) - logger.info(f"Scheduled retry {retry_count + 1} for screenshot {screenshot_id} in {retry_delay} seconds") - else: - logger.error(f"Max retries exceeded for page {page_id}") try: - screenshot.status = Screenshot.Status.FAILED - screenshot.error = f"Failed after {retry_count} attempts. Last error: {str(exc)}" - screenshot.save() - except: - pass + scheduler.add_job( + capture_screenshot, + 'date', + run_date=run_date, + args=[page_id, screenshot_id, screenshot.retry_count], + id=f'capture_screenshot_{screenshot_id}_retry_{screenshot.retry_count}', + replace_existing=True + ) + logger.info(f"Scheduled retry {screenshot.retry_count} for screenshot {screenshot_id} in {retry_delay}s") + except Exception as sched_exc: + logger.error(f"Failed to schedule retry for screenshot {screenshot_id}: {sched_exc}") + try: + screenshot.status = Screenshot.Status.FAILED + screenshot.error = f"Scheduler error, giving up: {str(sched_exc)}" + screenshot.save() + except Exception: + pass def process_page(page_id, retry_count=0): """Task to process a page and extract its metadata""" @@ -232,6 +282,10 @@ def process_page(page_id, retry_count=0): except Exception as exc: logger.error(f"Failed to process page {page_id}: {exc}") + try: + page = Page.objects.get(id=page_id) + except Page.DoesNotExist: + return page.retry_count += 1 page.last_retry_at = timezone.now() @@ -242,7 +296,7 @@ def process_page(page_id, retry_count=0): else: page.process_status = Page.ProcessStatus.PENDING page.save() - + # Schedule retry using APScheduler retry_delay = fibonacci(retry_count) from core.scheduler import scheduler @@ -277,48 +331,103 @@ def schedule_pending_pages(): thread.daemon = True thread.start() + +def schedule_pending_screenshots(): + """Periodic task to retry screenshots stuck in PENDING or PROCESSING state. + + Screenshots can get stuck when their background thread is killed (e.g. server + restart). Any screenshot that hasn't been updated in more than 5 minutes and + is not in a terminal state is considered stuck and will be re-queued — up to + the configured concurrency limit to prevent OOM. + """ + from .models import Screenshot, SiteSettings + + # First: mark any max-retried screenshots as FAILED so they stop being picked up + over_limit = Screenshot.objects.filter( + status__in=[Screenshot.Status.PENDING, Screenshot.Status.PROCESSING], + retry_count__gte=MAX_RETRIES, + ) + failed_count = over_limit.count() + if failed_count: + logger.warning(f"Marking {failed_count} screenshot(s) as FAILED (exceeded {MAX_RETRIES} retries)") + over_limit.update( + status=Screenshot.Status.FAILED, + error=f"Exceeded maximum retries ({MAX_RETRIES})", + ) + + # Determine how many slots are available + max_concurrent = SiteSettings.get().max_concurrent_screenshot_jobs + currently_processing = Screenshot.objects.filter( + status=Screenshot.Status.PROCESSING, + ).count() + available_slots = max(0, max_concurrent - currently_processing) + + if available_slots == 0: + logger.info(f"All {max_concurrent} screenshot slots busy, skipping stuck check") + return + + threshold = timezone.now() - timedelta(minutes=5) + stuck = Screenshot.objects.filter( + status__in=[Screenshot.Status.PENDING, Screenshot.Status.PROCESSING], + updated_at__lt=threshold, + retry_count__lt=MAX_RETRIES, + )[:available_slots] + + count = stuck.count() + if count: + logger.info(f"Found {count} stuck screenshot(s) — retrying (slots available: {available_slots})") + + for screenshot in stuck: + logger.info(f"Retrying screenshot {screenshot.id} (page {screenshot.page_id}, attempt {screenshot.retry_count + 1}/{MAX_RETRIES})") + screenshot.status = Screenshot.Status.PENDING + screenshot.retry_count += 1 + screenshot.save(update_fields=['status', 'retry_count', 'updated_at']) + thread = Thread(target=capture_screenshot, args=(screenshot.page_id, screenshot.id)) + thread.daemon = True + thread.start() + def fetch_webpage_content_from_crawl4ai(page_id, retry_count=0): """ Fetch webpage content using Crawl4AI /md endpoint """ from .models import Page - + if not settings.CRAWL4AI_ENABLED: logger.info("Crawl4AI is disabled, skipping webpage content extraction") return - + try: page = Page.objects.get(id=page_id) logger.info(f"Fetching webpage content for page {page_id} from Crawl4AI") - + # Call Crawl4AI /md endpoint api_url = f"{settings.CRAWL4AI_API_URL}/md" payload = { "url": page.url } - + response = requests.post( api_url, json=payload, timeout=60 ) response.raise_for_status() - + data = response.json() - + # Extract markdown content from response markdown_content = data.get('markdown', '') - + if markdown_content: page.content = markdown_content page.save() logger.info(f"Successfully fetched webpage content for page {page_id}") else: logger.warning(f"No markdown content returned for page {page_id}") - + except requests.RequestException as e: logger.error(f"Failed to fetch webpage content for page {page_id}: {e}") - + # Retry logic if retry_count < MAX_RETRIES: retry_delay = fibonacci(retry_count + 1) @@ -336,3 +445,180 @@ def fetch_webpage_content_from_crawl4ai(page_id, retry_count=0): logger.info(f"Scheduled Crawl4AI retry {retry_count + 1} for page {page_id} in {retry_delay} seconds") except Exception as e: logger.error(f"Unexpected error fetching webpage content for page {page_id}: {e}", exc_info=True) + + +def download_and_save_image(file_upload_id): + """Background task: download an external image and save it to FILE_UPLOADS_FOLDER. + + The FileUpload record must already exist with source_url set. This task fills in + the actual file content, size, and mime_type once the download completes. + """ + from .models import FileUpload + + try: + record = FileUpload.objects.get(pk=file_upload_id) + except FileUpload.DoesNotExist: + logger.error(f"download_and_save_image: FileUpload {file_upload_id} not found") + return + + source_url = record.source_url + if not source_url: + logger.error(f"download_and_save_image: FileUpload {file_upload_id} has no source_url") + return + + dest_path = record.file_path + if os.path.exists(dest_path): + logger.info(f"download_and_save_image: {dest_path} already exists, skipping download") + return + + logger.info(f"download_and_save_image: downloading {source_url} → {dest_path}") + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + } + response = requests.get(source_url, headers=headers, timeout=60, stream=True) + response.raise_for_status() + + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + content = response.content + + with open(dest_path, 'wb') as f: + f.write(content) + + mime_type = response.headers.get('Content-Type', 'application/octet-stream').split(';')[0].strip() + FileUpload.objects.filter(pk=file_upload_id).update( + size=len(content), + mime_type=mime_type, + ) + logger.info(f"download_and_save_image: saved {len(content)} bytes for FileUpload {file_upload_id}") + + except Exception as exc: + logger.error(f"download_and_save_image: failed for {source_url}: {exc}", exc_info=True) + # Clean up partial file if it exists + if os.path.exists(dest_path): + try: + os.remove(dest_path) + except OSError: + pass + # Remove the stub record so a size=0 ghost doesn't appear in the file list. + # The next import request for the same URL will create a fresh stub and retry. + try: + FileUpload.objects.filter(pk=file_upload_id, size=0).delete() + logger.info(f"download_and_save_image: deleted stub FileUpload {file_upload_id} after failed download") + except Exception as del_exc: + logger.error(f"download_and_save_image: could not delete stub {file_upload_id}: {del_exc}") + + +def flush_click_buffer(): + """Periodic task: flush Redis-buffered click counts into the database. + + redirect_to_original increments Redis keys of the form 'clicks:{link_id}:{date}' + instead of writing a ClickLog row per hit. This task drains those keys every ~60s + and bulk-inserts the ClickLog records plus updates Link.click_count in one shot. + """ + from .models import Link, ClickLog + from django_redis import get_redis_connection + + try: + redis_conn = get_redis_connection('default') + except Exception as e: + logger.error(f'flush_click_buffer: cannot get Redis connection: {e}') + return + + cursor = 0 + pattern = 'clicks:*' + # django-redis stores keys with a prefix like ':1:' — match it broadly + prefix = redis_conn.connection_pool.connection_kwargs.get('db', 0) + all_keys = [] + while True: + cursor, keys = redis_conn.scan(cursor, match=f'*clicks:*', count=200) + all_keys.extend(keys) + if cursor == 0: + break + + if not all_keys: + return + + logs_to_create = [] + link_count_deltas = {} # {link_id: total_clicks} + + pipeline = redis_conn.pipeline() + for key in all_keys: + pipeline.getdel(key) + counts = pipeline.execute() + + for raw_key, count_bytes in zip(all_keys, counts): + if not count_bytes: + continue + count = int(count_bytes) + if count <= 0: + continue + try: + # Key format: 'clicks:{link_id}:{date}' + key_str = raw_key.decode() if isinstance(raw_key, bytes) else raw_key + # Strip any cache key prefix (':1:clicks:...' → 'clicks:...') + parts = key_str.rsplit('clicks:', 1) + if len(parts) != 2: + continue + remainder = parts[1] # '{link_id}:{date}' + link_id_str, date_str = remainder.split(':', 1) + link_id = int(link_id_str) + except (ValueError, IndexError): + logger.warning(f'flush_click_buffer: unrecognised key format: {raw_key}') + continue + + link_count_deltas[link_id] = link_count_deltas.get(link_id, 0) + count + try: + click_date = timezone.datetime.strptime(date_str, '%Y-%m-%d').replace( + tzinfo=timezone.get_current_timezone() + ) + except ValueError: + click_date = timezone.now() + + for _ in range(count): + logs_to_create.append(ClickLog(link_id=link_id, clicked_at=click_date)) + + if logs_to_create: + ClickLog.objects.bulk_create(logs_to_create, ignore_conflicts=True) + logger.info(f'flush_click_buffer: inserted {len(logs_to_create)} ClickLog rows') + + for link_id, delta in link_count_deltas.items(): + Link.objects.filter(pk=link_id).update(click_count=F('click_count') + delta) + + if link_count_deltas: + logger.info(f'flush_click_buffer: updated click_count for {len(link_count_deltas)} link(s)') + + +def retry_stuck_image_imports(): + """Periodic task: retry imported images that are stuck with size=0 and no file on disk. + + A stub FileUpload (size=0, source_url set) can be left behind when the background + download thread is killed mid-flight (e.g. server restart). This task finds those + orphaned stubs and re-kicks the download, provided the stub is old enough that we + are confident it is not a currently in-progress download (>5 minutes since last update). + """ + from .models import FileUpload + + threshold = timezone.now() - timedelta(minutes=5) + stuck = FileUpload.objects.filter( + size=0, + source_url__isnull=False, + updated_at__lt=threshold, + ).exclude(source_url='') + + count = stuck.count() + if count: + logger.info(f"retry_stuck_image_imports: found {count} stuck import stub(s) — retrying") + + for record in stuck: + if os.path.exists(record.file_path): + # File landed on disk but DB wasn't updated — fix it now + size = os.path.getsize(record.file_path) + FileUpload.objects.filter(pk=record.pk, size=0).update(size=size) + logger.info(f"retry_stuck_image_imports: fixed size for FileUpload {record.pk} ({size} bytes)") + continue + + logger.info(f"retry_stuck_image_imports: re-queuing download for FileUpload {record.pk} ({record.source_url})") + thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True) + thread.start() diff --git a/links/templates/links/collection_detail.html b/links/templates/links/collection_detail.html index 70914de..8dacef7 100644 --- a/links/templates/links/collection_detail.html +++ b/links/templates/links/collection_detail.html @@ -3,7 +3,8 @@ {% load static %} {% block extra_css %} - + + +{% endblock %} + +{% block content %} +
+ + +
+

{% trans "Jobs" %}

+
+ + + {% if scheduler_running %}{% trans "Scheduler running" %}{% else %}{% trans "Scheduler stopped" %}{% endif %} + + {% trans "Max concurrent:" %} {{ site_settings.max_concurrent_screenshot_jobs }} + {% trans "Change" %} +
+
+ + {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + + +
+ {% for stat in job_type_stats %} +
+
+

+ + + + {{ stat.label }} +

+
+
+ +
{{ stat.total }}
+
{% trans "Total" %}
+
+ {% if stat.pending is not None %} + +
{{ stat.pending }}
+
{% trans "Pending" %}
+
+ {% endif %} + {% if stat.processing is not None %} + +
{{ stat.processing }}
+
{% trans "Running" %}
+
+ {% endif %} + {% if stat.completed is not None %} + +
{{ stat.completed }}
+
{% trans "Done" %}
+
+ {% endif %} + {% if stat.failed is not None %} + +
{{ stat.failed }}
+
{% trans "Failed" %}
+
+ {% endif %} +
+
+ {% endfor %} +
+ + +
+
+ + + + + {% if status_choices %} +
+ {% for value, label in status_choices %} + + {{ label }} + + {% endfor %} +
+ {% endif %} +
+
+ + +
+ + ☑ {% trans "Use the checkbox in the header row to select all, or check individual rows — then use bulk actions here" %} + +
+ +
+ + + +
+ +
+
+ + + + + +
+ + {% if tab == 'scheduler' %} + +
+ + + + ( {% trans "job(s)" %}) + +
+ {% if scheduled_jobs %} +
+ + + + + + + + + + + + +
{% trans "Job ID" %}{% trans "Function" %}{% trans "Trigger" %}{% trans "Next Run" %}
+
+
+ {% trans "No jobs match this filter." %} +
+ {% else %} +
{% trans "No scheduled jobs." %}
+ {% endif %} + + {% else %} + + {% if page_rows %} +
+ + + + + + + {% if 'source_url' in current_tab_config.columns %}{% endif %} + {% if 'status' in current_tab_config.columns %}{% endif %} + {% if 'retry' in current_tab_config.columns %}{% endif %} + {% if 'error' in current_tab_config.columns %}{% endif %} + {% if 'size' in current_tab_config.columns %}{% endif %} + + + + + {% for row in page_rows %} + + + + + {% if 'source_url' in current_tab_config.columns %} + + {% endif %} + {% if 'status' in current_tab_config.columns %} + + {% endif %} + {% if 'retry' in current_tab_config.columns %} + + {% endif %} + {% if 'error' in current_tab_config.columns %} + + {% endif %} + {% if 'size' in current_tab_config.columns %} + + {% endif %} + + + {% endfor %} + +
+ + ID{{ current_tab_config.title_label }}{% trans "Source URL" %}{% trans "Status" %}{% trans "Retries" %}{% trans "Error" %}{% trans "Size" %}{% trans "Updated" %}
+ + {{ row.id|truncatechars:12 }} + {% if row.detail_url and row.detail_url != '#' %} + + {{ row.title|truncatechars:55 }} + + {% else %} + {{ row.title|truncatechars:55 }} + {% endif %} + + + {{ row.extra.source_url|truncatechars:50 }} + + + + {{ row.status }} + + + {% if row.retry is not None %}{{ row.retry }}/{{ row.retry_max }}{% else %}{% endif %} + + {% if row.error %} + + {% else %}{% endif %} + + {% if row.extra.formatted_size %}{{ row.extra.formatted_size }}{% else %}{% endif %} + {{ row.updated_at|timesince }} {% trans "ago" %}
+
+ {% else %} +
{% trans "No items match this filter." %}
+ {% endif %} + + {% comment %}pages table removed — unified table above handles all types{% endcomment %} + {% if False %} +
+ + + + + + + + + + + + + + {% for pg in page_obj.object_list %} + + + + + + + + + + {% endfor %} + +
+ + ID{% trans "Title / URL" %}{% trans "Status" %}{% trans "Retries" %}{% trans "Error" %}{% trans "Updated" %}
+ + {{ pg.id }} + + {{ pg.title|default:pg.url|truncatechars:55 }} + + + + {{ pg.process_status }} + + {{ pg.retry_count }}/3 + {% if pg.error_message %} + + {% else %}{% endif %} + {{ pg.updated_at|timesince }} {% trans "ago" %}
+
+ {% else %} +
{% trans "No pages match this filter." %}
+ {% endif %} + + {% comment %}image_imports table removed — unified table above handles all types{% endcomment %} + {% if False %} + {% if page_obj.object_list %} +
+ + + + + + + + + + + + + + {% for imp in page_obj.object_list %} + + + + + + + + + + {% endfor %} + +
+ + ID{% trans "Filename" %}{% trans "Source URL" %}{% trans "Status" %}{% trans "Size" %}{% trans "Updated" %}
+ + {{ imp.id|truncatechars:12 }} + + {{ imp.name|truncatechars:40 }} + + + + {{ imp.source_url|truncatechars:50 }} + + + {% if imp.size > 0 %} + + {% trans "done" %} + + {% else %} + + {% trans "pending" %} + + {% endif %} + + {% if imp.size > 0 %}{{ imp.formatted_size }}{% else %}{% endif %} + {{ imp.updated_at|timesince }} {% trans "ago" %}
+
+ {% else %} +
{% trans "No image imports match this filter." %}
+ {% endif %} + {% endif %} + {% endif %} + + + {% if page_obj.has_other_pages %} +
+ + {% trans "Showing" %} {{ page_obj.start_index }}–{{ page_obj.end_index }} + {% trans "of" %} {{ page_obj.paginator.count }} + +
+ {% if page_obj.has_previous %} + ‹ {% trans "Prev" %} + {% endif %} + {% for num in page_obj.paginator.page_range %} + {% if page_obj.number == num %} + {{ num }} + {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} + {{ num }} + {% endif %} + {% endfor %} + {% if page_obj.has_next %} + {% trans "Next" %} › + {% endif %} +
+
+ {% endif %} +
+ + +
+
+
+
+

{% trans "Error Details" %}

+ +
+
+

{% trans "Job ID:" %}

+

+            
+
+ +
+
+
+ +
+ +{{ all_ids|json_script:"jobs-all-ids" }} +{{ scheduled_jobs|json_script:"scheduled-jobs-data" }} +{{ bulk_actions|json_script:"bulk-actions-data" }} + + +{% endblock %} diff --git a/links/templates/links/link_detail.html b/links/templates/links/link_detail.html index 5579d11..895ec52 100644 --- a/links/templates/links/link_detail.html +++ b/links/templates/links/link_detail.html @@ -223,7 +223,8 @@ {% endblock %} {% block extra_js %} - + + - + {# jQuery and Select2 are loaded globally from vendor in base.html #} + + +{% endblock %} diff --git a/links/templates/links/mini_apps/list.html b/links/templates/links/mini_apps/list.html index c24f7d7..447f118 100644 --- a/links/templates/links/mini_apps/list.html +++ b/links/templates/links/mini_apps/list.html @@ -25,43 +25,35 @@ class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"> - -
+ +
- -
- -
-
- -
-

{{ app.name }}

+ +
+
+
+

{{ app.name }}

+
- -

- {{ app.description }} -

- - + +
+

{{ app.description }}

{% if app.url != "#" %} {% trans "Launch App" %} {% else %} -
+
{% trans "Coming Soon" %}
{% endif %}
- - -
{% endfor %}
diff --git a/links/templates/links/page_detail.html b/links/templates/links/page_detail.html index dc3377b..eb5b503 100644 --- a/links/templates/links/page_detail.html +++ b/links/templates/links/page_detail.html @@ -4,7 +4,6 @@ {% load static %} {% block extra_head %} - @@ -99,7 +98,7 @@
-

{% trans "Tags" %}

+

{% trans "Tags" %}

{% for tag in page.tags.all %} diff --git a/links/templates/links/page_form.html b/links/templates/links/page_form.html index befbe16..bdac839 100644 --- a/links/templates/links/page_form.html +++ b/links/templates/links/page_form.html @@ -3,10 +3,8 @@ {% load static %} {% block extra_css %} - - - - + +{# Select2 CSS is loaded globally from vendor in base.html #} {% endblock %} @@ -357,8 +481,21 @@ - -
+ +
+ + + + + +

{{ post.title }}

@@ -381,15 +518,37 @@ {% trans "Edit" %} - + +
+ + + +
+ + +
+
+
+
+
+ + + + + + + + +
+
+
+

{% trans "Contents" %}

+ +
+ +
{% endblock %} {% block extra_js %} + {% endblock %} diff --git a/links/templates/links/post_form.html b/links/templates/links/post_form.html index 795741d..a593a6c 100644 --- a/links/templates/links/post_form.html +++ b/links/templates/links/post_form.html @@ -3,10 +3,8 @@ {% load static %} {% block extra_css %} - - - - + +{# Select2 CSS now loaded globally from vendor in base.html #} -
- -
-
-
-

{{ post.title }}

-
- {{ post.created_at|date:"Y-m-d H:i" }} -
+ +
+ + +
+ - -
- {% markdown_with_tasks post.content post.task_states %} + +
+ +
+
+
+

{{ post.title }}

+
+ {{ post.created_at|date:"Y-m-d H:i" }} +
+
+
+ {% if post.tags.exists %} +
+ {% for tag in post.tags.all %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} +
+ + +
+ {% markdown_with_tasks post.content post.task_states %} +
+
+
+ + + + + + + + +
+
+
+

Contents

+ +
+
@@ -146,6 +255,130 @@ }); }); }); + + // ============================================================ + // Table of Contents + // ============================================================ + (function() { + function buildTOC() { + const proseEl = document.querySelector('.prose'); + if (!proseEl) return; + + const headings = proseEl.querySelectorAll('h2, h3, h4'); + if (headings.length < 2) { + const sidebar = document.getElementById('toc-sidebar'); + const mobileBtn = document.getElementById('toc-mobile-btn'); + if (sidebar) sidebar.style.display = 'none'; + if (mobileBtn) mobileBtn.style.display = 'none'; + return; + } + + function createLinks(navEl) { + navEl.innerHTML = ''; + headings.forEach(function(heading) { + if (!heading.id) { + heading.id = heading.textContent.trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + const level = parseInt(heading.tagName[1]); + const link = document.createElement('a'); + link.href = '#' + heading.id; + link.textContent = heading.textContent; + link.classList.add('toc-link', 'toc-h' + level); + link.setAttribute('data-heading-id', heading.id); + link.style.paddingLeft = ((level - 2) * 12 + 8) + 'px'; + link.addEventListener('click', function(e) { + e.preventDefault(); + closeMobileDrawer(); + const target = document.getElementById(heading.id); + if (target) { + const y = target.getBoundingClientRect().top + window.scrollY - 24; + window.scrollTo({ top: y, behavior: 'smooth' }); + } + }); + navEl.appendChild(link); + }); + } + + const tocNav = document.getElementById('toc-nav'); + const tocNavMobile = document.getElementById('toc-nav-mobile'); + if (tocNav) createLinks(tocNav); + if (tocNavMobile) createLinks(tocNavMobile); + + // Scroll-spy: find heading closest above the viewport threshold + var _rafPending = false; + function onScroll() { + if (_rafPending) return; + _rafPending = true; + requestAnimationFrame(function() { + _rafPending = false; + var threshold = 40; + var activeId = null; + for (var i = headings.length - 1; i >= 0; i--) { + if (headings[i].getBoundingClientRect().top <= threshold) { + activeId = headings[i].id; + break; + } + } + if (!activeId && headings.length > 0) activeId = headings[0].id; + highlightTOC(activeId); + var activeLinkInSidebar = document.querySelector('#toc-nav .toc-link.toc-active'); + if (activeLinkInSidebar) { + var sidebarInner = document.getElementById('toc-sidebar-inner'); + if (sidebarInner) { + var linkTop = activeLinkInSidebar.offsetTop; + var linkH = activeLinkInSidebar.offsetHeight; + var scrollTop = sidebarInner.scrollTop; + var sidebarH = sidebarInner.clientHeight; + if (linkTop < scrollTop) sidebarInner.scrollTop = linkTop - 8; + else if (linkTop + linkH > scrollTop + sidebarH) sidebarInner.scrollTop = linkTop + linkH - sidebarH + 8; + } + } + }); + } + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + + function highlightTOC(id) { + document.querySelectorAll('.toc-link').forEach(function(a) { + if (id && a.getAttribute('data-heading-id') === id) { + a.classList.add('toc-active'); + } else { + a.classList.remove('toc-active'); + } + }); + } + } + + function openMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(0)'; + if (backdrop) backdrop.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + function closeMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(-100%)'; + if (backdrop) backdrop.classList.add('hidden'); + document.body.style.overflow = ''; + } + + document.addEventListener('DOMContentLoaded', function() { + buildTOC(); + const mobileBtn = document.getElementById('toc-mobile-btn'); + const mobileClose = document.getElementById('toc-mobile-close'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (mobileBtn) mobileBtn.addEventListener('click', openMobileDrawer); + if (mobileClose) mobileClose.addEventListener('click', closeMobileDrawer); + if (backdrop) backdrop.addEventListener('click', closeMobileDrawer); + }); + })(); diff --git a/links/templates/links/search_react.html b/links/templates/links/search_react.html index df77032..1c186ba 100644 --- a/links/templates/links/search_react.html +++ b/links/templates/links/search_react.html @@ -5,11 +5,8 @@ Advanced Search - GoLinks - - - - - + + ")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/static/vendor/jquery/jquery.min.js b/static/vendor/jquery/jquery.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/static/vendor/jquery/jquery.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0.select2-results__options{max-height:200px;overflow-y:auto}.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--group{padding:0}.select2-container--default .select2-results__option--disabled{color:#999}.select2-container--default .select2-results__option--selected{background-color:#ddd}.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable{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;height:26px;margin-right:20px}.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;padding-bottom:5px;padding-right:5px}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.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;display:inline-block;margin-left:5px;margin-top:5px;padding:0}.select2-container--classic .select2-selection--multiple .select2-selection__choice__display{cursor:default;padding-left:2px;padding-right:5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{background-color:transparent;border:none;border-top-left-radius:4px;border-bottom-left-radius:4px;color:#888;cursor:pointer;font-size:1em;font-weight:bold;padding:0 4px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555;outline:none}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display{padding-left:5px;padding-right:2px}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:4px;border-bottom-right-radius:4px}.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--group{padding:0}.select2-container--classic .select2-results__option--disabled{color:grey}.select2-container--classic .select2-results__option--highlighted.select2-results__option--selectable{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/vendor/select2/js/select2.min.js b/static/vendor/select2/js/select2.min.js new file mode 100644 index 0000000..cc9a83f --- /dev/null +++ b/static/vendor/select2/js/select2.min.js @@ -0,0 +1,2 @@ +/*! Select2 4.1.0-rc.0 | https://github.com/select2/select2/blob/master/LICENSE.md */ +!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(t){var e,n,s,p,r,o,h,f,g,m,y,v,i,a,_,s=((u=t&&t.fn&&t.fn.select2&&t.fn.select2.amd?t.fn.select2.amd:u)&&u.requirejs||(u?n=u:u={},g={},m={},y={},v={},i=Object.prototype.hasOwnProperty,a=[].slice,_=/\.js$/,h=function(e,t){var n,s,i=c(e),r=i[0],t=t[1];return e=i[1],r&&(n=x(r=l(r,t))),r?e=n&&n.normalize?n.normalize(e,(s=t,function(e){return l(e,s)})):l(e,t):(r=(i=c(e=l(e,t)))[0],e=i[1],r&&(n=x(r))),{f:r?r+"!"+e:e,n:e,pr:r,p:n}},f={require:function(e){return w(e)},exports:function(e){var t=g[e];return void 0!==t?t:g[e]={}},module:function(e){return{id:e,uri:"",exports:g[e],config:(t=e,function(){return y&&y.config&&y.config[t]||{}})};var t}},r=function(e,t,n,s){var i,r,o,a,l,c=[],u=typeof n,d=A(s=s||e);if("undefined"==u||"function"==u){for(t=!t.length&&n.length?["require","exports","module"]:t,a=0;a":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},s.__cache={};var n=0;return s.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null!=t||(t=e.id?"select2-data-"+e.id:"select2-data-"+(++n).toString()+"-"+s.generateChars(4),e.setAttribute("data-select2-id",t)),t},s.StoreData=function(e,t,n){e=s.GetUniqueElementId(e);s.__cache[e]||(s.__cache[e]={}),s.__cache[e][t]=n},s.GetData=function(e,t){var n=s.GetUniqueElementId(e);return t?s.__cache[n]&&null!=s.__cache[n][t]?s.__cache[n][t]:r(e).data(t):s.__cache[n]},s.RemoveData=function(e){var t=s.GetUniqueElementId(e);null!=s.__cache[t]&&delete s.__cache[t],e.removeAttribute("data-select2-id")},s.copyNonInternalCssClasses=function(e,t){var n=(n=e.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0===e.indexOf("select2-")}),t=(t=t.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0!==e.indexOf("select2-")}),t=n.concat(t);e.setAttribute("class",t.join(" "))},s}),u.define("select2/results",["jquery","./utils"],function(d,p){function s(e,t,n){this.$element=e,this.data=n,this.options=t,s.__super__.constructor.call(this)}return p.Extend(s,p.Observable),s.prototype.render=function(){var e=d('
    ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},s.prototype.clear=function(){this.$results.empty()},s.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=d(''),s=this.options.get("translations").get(e.message);n.append(t(s(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},s.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},s.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested",role:"none"});i.append(l),o.append(a),o.append(i)}else this.template(e,t);return p.StoreData(t,"data",e),t},s.prototype.bind=function(t,e){var i=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){i.clear(),i.append(e.data),t.isOpen()&&(i.setClasses(),i.highlightFirstItem())}),t.on("results:append",function(e){i.append(e.data),t.isOpen()&&i.setClasses()}),t.on("query",function(e){i.hideMessages(),i.showLoading(e)}),t.on("select",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("open",function(){i.$results.attr("aria-expanded","true"),i.$results.attr("aria-hidden","false"),i.setClasses(),i.ensureHighlightVisible()}),t.on("close",function(){i.$results.attr("aria-expanded","false"),i.$results.attr("aria-hidden","true"),i.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=i.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e,t=i.getHighlightedResults();0!==t.length&&(e=p.GetData(t[0],"data"),t.hasClass("select2-results__option--selected")?i.trigger("close",{}):i.trigger("select",{data:e}))}),t.on("results:previous",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t);s<=0||(e=s-1,0===t.length&&(e=0),(s=n.eq(e)).trigger("mouseenter"),t=i.$results.offset().top,n=s.offset().top,s=i.$results.scrollTop()+(n-t),0===e?i.$results.scrollTop(0):n-t<0&&i.$results.scrollTop(s))}),t.on("results:next",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t)+1;s>=n.length||((e=n.eq(s)).trigger("mouseenter"),t=i.$results.offset().top+i.$results.outerHeight(!1),n=e.offset().top+e.outerHeight(!1),e=i.$results.scrollTop()+n-t,0===s?i.$results.scrollTop(0):tthis.$results.outerHeight()||s<0)&&this.$results.scrollTop(n))},s.prototype.template=function(e,t){var n=this.options.get("templateResult"),s=this.options.get("escapeMarkup"),e=n(e,t);null==e?t.style.display="none":"string"==typeof e?t.innerHTML=s(e):d(t).append(e)},s}),u.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),u.define("select2/selection/base",["jquery","../utils","../keys"],function(n,s,i){function r(e,t){this.$element=e,this.options=t,r.__super__.constructor.call(this)}return s.Extend(r,s.Observable),r.prototype.render=function(){var e=n('');return this._tabindex=0,null!=s.GetData(this.$element[0],"old-tabindex")?this._tabindex=s.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},r.prototype.bind=function(e,t){var n=this,s=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===i.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",s),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},r.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},r.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&s.GetData(this,"element").select2("close")})})},r.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},r.prototype.position=function(e,t){t.find(".selection").append(e)},r.prototype.destroy=function(){this._detachCloseHandler(this.container)},r.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},r.prototype.isEnabled=function(){return!this.isDisabled()},r.prototype.isDisabled=function(){return this.options.get("disabled")},r}),u.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,s){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e[0].classList.add("select2-selection--single"),e.html(''),e},i.prototype.bind=function(t,e){var n=this;i.__super__.bind.apply(this,arguments);var s=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",s),this.$selection.attr("aria-controls",s),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},i.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},i.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},i.prototype.selectionContainer=function(){return e("")},i.prototype.update=function(e){var t,n;0!==e.length?(n=e[0],t=this.$selection.find(".select2-selection__rendered"),e=this.display(n,t),t.empty().append(e),(n=n.title||n.text)?t.attr("title",n):t.removeAttr("title")):this.clear()},i}),u.define("select2/selection/multiple",["jquery","./base","../utils"],function(i,e,c){function r(e,t){r.__super__.constructor.apply(this,arguments)}return c.Extend(r,e),r.prototype.render=function(){var e=r.__super__.render.call(this);return e[0].classList.add("select2-selection--multiple"),e.html('
      '),e},r.prototype.bind=function(e,t){var n=this;r.__super__.bind.apply(this,arguments);var s=e.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s),this.$selection.on("click",function(e){n.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){var t;n.isDisabled()||(t=i(this).parent(),t=c.GetData(t[0],"data"),n.trigger("unselect",{originalEvent:e,data:t}))}),this.$selection.on("keydown",".select2-selection__choice__remove",function(e){n.isDisabled()||e.stopPropagation()})},r.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},r.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},r.prototype.selectionContainer=function(){return i('
    • ')},r.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=this.$selection.find(".select2-selection__rendered").attr("id")+"-choice-",s=0;s')).attr("title",s()),e.attr("aria-label",s()),e.attr("aria-describedby",n),a.StoreData(e[0],"data",t),this.$selection.prepend(e),this.$selection[0].classList.add("select2-selection--clearable"))},e}),u.define("select2/selection/search",["jquery","../utils","../keys"],function(s,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=this.options.get("translations").get("search"),n=s('');this.$searchContainer=n,this.$search=n.find("textarea"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",t());e=e.call(this);return this._transferTabIndex(),e.append(this.$searchContainer),e},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results",r=t.id+"-container";e.call(this,t,n),s.$search.attr("aria-describedby",r),t.on("open",function(){s.$search.attr("aria-controls",i),s.$search.trigger("focus")}),t.on("close",function(){s.$search.val(""),s.resizeSearch(),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.trigger("focus")}),t.on("enable",function(){s.$search.prop("disabled",!1),s._transferTabIndex()}),t.on("disable",function(){s.$search.prop("disabled",!0)}),t.on("focus",function(e){s.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){s.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){s._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){var t;e.stopPropagation(),s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented(),e.which!==l.BACKSPACE||""!==s.$search.val()||0<(t=s.$selection.find(".select2-selection__choice").last()).length&&(t=a.GetData(t[0],"data"),s.searchRemoveChoice(t),e.preventDefault())}),this.$selection.on("click",".select2-search--inline",function(e){s.$search.val()&&e.stopPropagation()});var t=document.documentMode,o=t&&t<=11;this.$selection.on("input.searchcheck",".select2-search--inline",function(e){o?s.$selection.off("input.search input.searchcheck"):s.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(e){var t;o&&"input"===e.type?s.$selection.off("input.search input.searchcheck"):(t=e.which)!=l.SHIFT&&t!=l.CTRL&&t!=l.ALT&&t!=l.TAB&&s.handleSearch(e)})},e.prototype._transferTabIndex=function(e){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},e.prototype.createPlaceholder=function(e,t){this.$search.attr("placeholder",t.text)},e.prototype.update=function(e,t){var n=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),e.call(this,t),this.resizeSearch(),n&&this.$search.trigger("focus")},e.prototype.handleSearch=function(){var e;this.resizeSearch(),this._keyUpPrevented||(e=this.$search.val(),this.trigger("query",{term:e})),this._keyUpPrevented=!1},e.prototype.searchRemoveChoice=function(e,t){this.trigger("unselect",{data:t}),this.$search.val(t.text),this.handleSearch()},e.prototype.resizeSearch=function(){this.$search.css("width","25px");var e="100%";""===this.$search.attr("placeholder")&&(e=.75*(this.$search.val().length+1)+"em"),this.$search.css("width",e)},e}),u.define("select2/selection/selectionCss",["../utils"],function(n){function e(){}return e.prototype.render=function(e){var t=e.call(this),e=this.options.get("selectionCssClass")||"";return-1!==e.indexOf(":all:")&&(e=e.replace(":all:",""),n.copyNonInternalCssClasses(t[0],this.$element[0])),t.addClass(e),t},e}),u.define("select2/selection/eventRelay",["jquery"],function(o){function e(){}return e.prototype.bind=function(e,t,n){var s=this,i=["open","opening","close","closing","select","selecting","unselect","unselecting","clear","clearing"],r=["opening","closing","selecting","unselecting","clearing"];e.call(this,t,n),t.on("*",function(e,t){var n;-1!==i.indexOf(e)&&(t=t||{},n=o.Event("select2:"+e,{params:t}),s.$element.trigger(n),-1!==r.indexOf(e)&&(t.prevented=n.isDefaultPrevented()))})},e}),u.define("select2/translation",["jquery","require"],function(t,n){function s(e){this.dict=e||{}}return s.prototype.all=function(){return this.dict},s.prototype.get=function(e){return this.dict[e]},s.prototype.extend=function(e){this.dict=t.extend({},e.all(),this.dict)},s._cache={},s.loadPath=function(e){var t;return e in s._cache||(t=n(e),s._cache[e]=t),new s(s._cache[e])},s}),u.define("select2/diacritics",[],function(){return{"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Œ":"OE","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","œ":"oe","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ώ":"ω","ς":"σ","’":"'"}}),u.define("select2/data/base",["../utils"],function(n){function s(e,t){s.__super__.constructor.call(this)}return n.Extend(s,n.Observable),s.prototype.current=function(e){throw new Error("The `current` method must be defined in child classes.")},s.prototype.query=function(e,t){throw new Error("The `query` method must be defined in child classes.")},s.prototype.bind=function(e,t){},s.prototype.destroy=function(){},s.prototype.generateResultId=function(e,t){e=e.id+"-result-";return e+=n.generateChars(4),null!=t.id?e+="-"+t.id.toString():e+="-"+n.generateChars(4),e},s}),u.define("select2/data/select",["./base","../utils","jquery"],function(e,a,l){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return a.Extend(n,e),n.prototype.current=function(e){var t=this;e(Array.prototype.map.call(this.$element[0].querySelectorAll(":checked"),function(e){return t.item(l(e))}))},n.prototype.select=function(i){var e,r=this;if(i.selected=!0,null!=i.element&&"option"===i.element.tagName.toLowerCase())return i.element.selected=!0,void this.$element.trigger("input").trigger("change");this.$element.prop("multiple")?this.current(function(e){var t=[];(i=[i]).push.apply(i,e);for(var n=0;nthis.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),u.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("select",function(){s._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var s=this;this._checkIfMaximumSelected(function(){e.call(s,t,n)})},e.prototype._checkIfMaximumSelected=function(e,t){var n=this;this.current(function(e){e=null!=e?e.length:0;0=n.maximumSelectionLength?n.trigger("results:message",{message:"maximumSelected",args:{maximum:n.maximumSelectionLength}}):t&&t()})},e}),u.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),u.define("select2/dropdown/search",["jquery"],function(r){function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("translations").get("search"),e=r('');return this.$searchContainer=e,this.$search=e.find("input"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",n()),t.prepend(e),t},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){r(this).off("keyup")}),this.$search.on("keyup input",function(e){s.handleSearch(e)}),t.on("open",function(){s.$search.attr("tabindex",0),s.$search.attr("aria-controls",i),s.$search.trigger("focus"),window.setTimeout(function(){s.$search.trigger("focus")},0)}),t.on("close",function(){s.$search.attr("tabindex",-1),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.val(""),s.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||s.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(s.showSearch(e)?s.$searchContainer[0].classList.remove("select2-search--hide"):s.$searchContainer[0].classList.add("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")})},e.prototype.handleSearch=function(e){var t;this._keyUpPrevented||(t=this.$search.val(),this.trigger("query",{term:t})),this._keyUpPrevented=!1},e.prototype.showSearch=function(e,t){return!0},e}),u.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,s){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,s)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return t="string"==typeof t?{id:"",text:t}:t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),s=t.length-1;0<=s;s--){var i=t[s];this.placeholder.id===i.id&&n.splice(s,1)}return n},e}),u.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,s){this.lastParams={},e.call(this,t,n,s),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("query",function(e){s.lastParams=e,s.loading=!0}),t.on("query:append",function(e){s.lastParams=e,s.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);!this.loading&&e&&(e=this.$results.offset().top+this.$results.outerHeight(!1),this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=e+50&&this.loadMore())},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
    • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),u.define("select2/dropdown/attachBody",["jquery","../utils"],function(u,o){function e(e,t,n){this.$dropdownParent=u(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("open",function(){s._showDropdown(),s._attachPositioningHandler(t),s._bindContainerResultHandlers(t)}),t.on("close",function(){s._hideDropdown(),s._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t[0].classList.remove("select2"),t[0].classList.add("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=u(""),e=e.call(this);return t.append(e),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){var n;this._containerResultsHandlersBound||(n=this,t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0)},e.prototype._attachPositioningHandler=function(e,t){var n=this,s="scroll.select2."+t.id,i="resize.select2."+t.id,r="orientationchange.select2."+t.id,t=this.$container.parents().filter(o.hasScroll);t.each(function(){o.StoreData(this,"select2-scroll-position",{x:u(this).scrollLeft(),y:u(this).scrollTop()})}),t.on(s,function(e){var t=o.GetData(this,"select2-scroll-position");u(this).scrollTop(t.y)}),u(window).on(s+" "+i+" "+r,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,s="resize.select2."+t.id,t="orientationchange.select2."+t.id;this.$container.parents().filter(o.hasScroll).off(n),u(window).off(n+" "+s+" "+t)},e.prototype._positionDropdown=function(){var e=u(window),t=this.$dropdown[0].classList.contains("select2-dropdown--above"),n=this.$dropdown[0].classList.contains("select2-dropdown--below"),s=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var r={height:this.$container.outerHeight(!1)};r.top=i.top,r.bottom=i.top+r.height;var o=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+o,a={left:i.left,top:r.bottom},l=this.$dropdownParent;"static"===l.css("position")&&(l=l.offsetParent());i={top:0,left:0};(u.contains(document.body,l[0])||l[0].isConnected)&&(i=l.offset()),a.top-=i.top,a.left-=i.left,t||n||(s="below"),e||!c||t?!c&&e&&t&&(s="below"):s="above",("above"==s||t&&"below"!==s)&&(a.top=r.top-i.top-o),null!=s&&(this.$dropdown[0].classList.remove("select2-dropdown--below"),this.$dropdown[0].classList.remove("select2-dropdown--above"),this.$dropdown[0].classList.add("select2-dropdown--"+s),this.$container[0].classList.remove("select2-container--below"),this.$container[0].classList.remove("select2-container--above"),this.$container[0].classList.add("select2-container--"+s)),this.$dropdownContainer.css(a)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),u.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,s){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,s)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,s=0;s');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container[0].classList.add("select2-container--"+this.options.get("theme")),r.StoreData(e[0],"element",this.$element),e},o}),u.define("jquery-mousewheel",["jquery"],function(e){return e}),u.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,r,t,o){var a;return null==i.fn.select2&&(a=["open","close","destroy"],i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new r(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,s=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=o.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,s)}),-1 { + const variants = { + default: "bg-gray-100 text-gray-800", + link: "bg-blue-100 text-blue-800", + page: "bg-green-100 text-green-800", + post: "bg-purple-100 text-purple-800" + }; + return ( + + {children} + + ); +}; + +// Card Component +const Card = ({ children, className = "", hover = false }) => ( +
      + {children} +
      +); + +// Input Component +const Input = ({ className = "", ...props }) => ( + +); + +// Select Component +const Select = ({ children, className = "", ...props }) => ( + +); + +// Button Component +const Button = ({ children, variant = "default", className = "", ...props }) => { + const variants = { + default: "bg-blue-600 text-white hover:bg-blue-700", + outline: "border-2 border-gray-300 bg-white hover:bg-gray-50", + ghost: "hover:bg-gray-100" + }; + return ( + + ); +}; + +// Skeleton Loader +const Skeleton = ({ className = "" }) => ( +
      +); + +// Search Result Item Component +const SearchResultItem = ({ result }) => { + const typeIcons = { + link: "fa-link", + page: "fa-file-alt", + post: "fa-newspaper" + }; + + const formatDate = (dateString) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + return ( + +
      +
      +
      + +
      +
      + +
      +
      + + {result.title} + + + {result.type} + +
      + + {result.type === 'link' && result.original_url && ( + + + {result.original_url} + + )} + + {(result.description || result.summary) && ( +

      + {result.description || result.summary} +

      + )} + +
      + + + {formatDate(result.created_at)} + + {result.click_count !== undefined && ( + + + {result.click_count} clicks + + )} +
      + + {result.tags && result.tags.length > 0 && ( +
      + {result.tags.map(tag => ( + + + {tag.name} + + ))} +
      + )} +
      + +
      + + + +
      +
      +
      + ); +}; + +// Main Search App Component +const SearchApp = () => { + const [query, setQuery] = useState(''); + const [type, setType] = useState(''); + const [sort, setSort] = useState('relevance'); + const [isVector, setIsVector] = useState(false); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [error, setError] = useState(null); + const perPage = 20; + + const performSearch = useCallback(async (searchQuery, searchType, searchSort, searchPage) => { + if (!searchQuery.trim()) { + setResults([]); + setTotal(0); + return; + } + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams({ + q: searchQuery, + type: searchType, + sort: searchSort, + page: searchPage, + per_page: perPage + }); + + const endpoint = isVector ? "/search/api/vector/" : "/search/api/v2/"; + const response = await fetch(`${endpoint}?${params}`); + const data = await response.json(); + + if (response.ok) { + setResults(data.results); + setTotal(data.total); + } else { + setError(data.error || 'Search failed'); + setResults([]); + } + } catch (err) { + setError('Network error. Please try again.'); + setResults([]); + } finally { + setLoading(false); + } + }, [isVector]); + + const handleSearch = useCallback((e) => { + e.preventDefault(); + setPage(1); + performSearch(query, type, sort, 1); + }, [query, type, sort, performSearch]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const urlQuery = params.get('q') || ''; + const urlType = params.get('type') || ''; + const urlSort = params.get('sort') || 'relevance'; + + setQuery(urlQuery); + setType(urlType); + setSort(urlSort); + + if (urlQuery) { + performSearch(urlQuery, urlType, urlSort, 1); + } + }, [performSearch]); + + useEffect(() => { + if (query) { + const params = new URLSearchParams({ + q: query, + ...(type && { type }), + ...(sort !== 'relevance' && { sort }) + }); + window.history.replaceState({}, '', `?${params}`); + } + }, [query, type, sort]); + + const handlePageChange = (newPage) => { + setPage(newPage); + performSearch(query, type, sort, newPage); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const totalPages = Math.ceil(total / perPage); + + return ( +
      +
      + {/* Header */} +
      + +

      + + Advanced Search +

      +
      +

      Search through all Links, Pages, and Posts

      +
      + + {/* Search Form */} + +
      + {/* Search Input */} +
      + +
      + + setQuery(e.target.value)} + placeholder="Enter keywords to search..." + className="pl-10" + /> +
      +
      + + {/* Filters */} +
      +
      + + +
      + +
      + +
      + + setIsVector(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> +
      + +
      +
      + + {/* Search Button */} + +
      +
      + + {/* Results */} + {query && ( +
      + {/* Results Header */} +
      +

      + {loading ? ( + 'Searching...' + ) : error ? ( + + + {error} + + ) : ( + <> + Search results for: "{query}" + {total > 0 && ( + + ({total} {total === 1 ? 'result' : 'results'}) + + )} + + )} +

      +
      + + {/* Loading Skeletons */} + {loading && ( +
      + {[1, 2, 3].map(i => ( + +
      + +
      + + + +
      +
      +
      + ))} +
      + )} + + {/* Results List */} + {!loading && !error && results.length > 0 && ( +
      + {results.map(result => ( + + ))} +
      + )} + + {/* No Results */} + {!loading && !error && results.length === 0 && query && ( + + +

      No results found

      +

      Try adjusting your search terms or filters

      +
      + )} + + {/* Pagination */} + {!loading && totalPages > 1 && ( +
      + + +
      + {[...Array(Math.min(5, totalPages))].map((_, i) => { + let pageNum; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (page <= 3) { + pageNum = i + 1; + } else if (page >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = page - 2 + i; + } + + return ( + + ); + })} +
      + + +
      + )} +
      + )} + + {/* Empty State */} + {!query && ( + + +

      Start Searching

      +

      + Enter a search query to find links, pages, and posts. + You can search by title, content, URL, or tags. +

      +
      + )} +
      +
      + ); +}; + +// Mount app +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/tailwind.config.js b/tailwind.config.js index 988616d..7ef8fc2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,12 @@ module.exports = { content: [ - // ... + './templates/**/*.html', + './links/templates/**/*.html', + './new_theme/templates/**/*.html', + './netscan/templates/**/*.html', + './nginxmon/templates/**/*.html', + './static_src/**/*.{js,jsx}', + './**/*.js', ], theme: { extend: {}, diff --git a/templates/base.html b/templates/base.html index 13900fa..f20c54b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,7 +7,7 @@ {% trans "GoLinks" %} - +