diff --git a/app/local_settings.py b/app/local_settings.py index ef0962e..d5437db 100644 --- a/app/local_settings.py +++ b/app/local_settings.py @@ -48,7 +48,13 @@ if DEBUG: SETTINGS_EXPORT = ["BILLING_ENABLED"] SIGNAL_NUMBER = getenv("SIGNAL_NUMBER") -SIGNAL_HTTP_URL = getenv("SIGNAL_HTTP_URL", "http://signal:8080") +_container_runtime = getenv("container", "").strip().lower() +_signal_default_url = ( + "http://127.0.0.1:8080" + if _container_runtime == "podman" + else "http://signal:8080" +) +SIGNAL_HTTP_URL = getenv("SIGNAL_HTTP_URL", _signal_default_url) WHATSAPP_ENABLED = getenv("WHATSAPP_ENABLED", "false").lower() in trues WHATSAPP_HTTP_URL = getenv("WHATSAPP_HTTP_URL", "http://whatsapp:8080") diff --git a/app/urls.py b/app/urls.py index 9f45d72..df510de 100644 --- a/app/urls.py +++ b/app/urls.py @@ -305,6 +305,16 @@ urlpatterns = [ osint.OSINTSearch.as_view(), name="osint_search", ), + path( + "osint/workspace/", + osint.OSINTWorkspace.as_view(), + name="osint_workspace", + ), + path( + "osint/workspace/widget/tabs/", + osint.OSINTWorkspaceTabsWidget.as_view(), + name="osint_workspace_tabs_widget", + ), path( "ai//create/", ais.AICreate.as_view(), diff --git a/core/clients/signal.py b/core/clients/signal.py index 10de7f2..92c6ba9 100644 --- a/core/clients/signal.py +++ b/core/clients/signal.py @@ -1,5 +1,6 @@ import asyncio import json +import os from urllib.parse import urlparse import aiohttp @@ -21,7 +22,12 @@ if _signal_http_url: parsed = urlparse( _signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}" ) - SIGNAL_HOST = parsed.hostname or "signal" + configured_host = (parsed.hostname or "").strip().lower() + runtime = os.getenv("container", "").strip().lower() + if configured_host == "signal" and runtime == "podman": + SIGNAL_HOST = "127.0.0.1" + else: + SIGNAL_HOST = parsed.hostname or "signal" SIGNAL_PORT = parsed.port or 8080 else: if settings.DEBUG: @@ -141,6 +147,18 @@ def _typing_started(typing_payload): return True +def _identifier_candidates(*values): + out = [] + seen = set() + for value in values: + cleaned = str(value or "").strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + out.append(cleaned) + return out + + class NewSignalBot(SignalBot): def __init__(self, ur, service, config): self.ur = ur @@ -242,6 +260,8 @@ class HandleMessage(Command): source_uuid = c.message.source_uuid text = c.message.text ts = c.message.timestamp + source_value = c.message.source + envelope = raw.get("envelope", {}) # Message originating from us same_recipient = source_uuid == dest @@ -253,21 +273,33 @@ class HandleMessage(Command): reply_to_others = is_to_bot and not same_recipient # Reply is_outgoing_message = is_from_bot and not is_to_bot # Do not reply - # Determine the identifier to use - identifier_uuid = dest if is_from_bot else source_uuid - if not identifier_uuid: + envelope_source_uuid = envelope.get("sourceUuid") + envelope_source_number = envelope.get("sourceNumber") + envelope_source = envelope.get("source") + + primary_identifier = dest if is_from_bot else source_uuid + identifier_candidates = _identifier_candidates( + primary_identifier, + source_uuid, + source_number, + source_value, + envelope_source_uuid, + envelope_source_number, + envelope_source, + dest, + ) + if not identifier_candidates: log.warning("No Signal identifier available for message routing.") return # Resolve person identifiers once for this event. identifiers = await sync_to_async(list)( PersonIdentifier.objects.filter( - identifier=identifier_uuid, + identifier__in=identifier_candidates, service=self.service, ) ) - envelope = raw.get("envelope", {}) typing_payload = envelope.get("typingMessage") if isinstance(typing_payload, dict): for identifier in identifiers: @@ -300,7 +332,7 @@ class HandleMessage(Command): message_timestamps=read_timestamps, read_ts=read_ts, payload=receipt_payload, - read_by=source_uuid, + read_by=(source_uuid or source_number or ""), ) return @@ -380,21 +412,44 @@ class HandleMessage(Command): attachments=xmpp_attachments, ) - # TODO: Permission checks - manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True)) + # Persist message history for every resolved identifier, even when no + # manipulations are active, so manual chat windows stay complete. session_cache = {} stored_messages = set() + for identifier in identifiers: + session_key = (identifier.user.id, identifier.person.id) + if session_key in session_cache: + chat_session = session_cache[session_key] + else: + chat_session = await history.get_chat_session(identifier.user, identifier) + session_cache[session_key] = chat_session + sender_key = source_uuid or source_number or identifier_candidates[0] + message_key = (chat_session.id, ts, sender_key) + if message_key not in stored_messages: + await history.store_message( + session=chat_session, + sender=sender_key, + text=text, + ts=ts, + outgoing=is_from_bot, + ) + stored_messages.add(message_key) + + # TODO: Permission checks + manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True)) for manip in manips: - try: - person_identifier = await sync_to_async(PersonIdentifier.objects.get)( - identifier=identifier_uuid, + person_identifier = await sync_to_async( + lambda: PersonIdentifier.objects.filter( + identifier__in=identifier_candidates, user=manip.user, service="signal", person__in=manip.group.people.all(), - ) - except PersonIdentifier.DoesNotExist: + ).first() + )() + if person_identifier is None: log.warning( - f"{manip.name}: Message from unknown identifier {identifier_uuid}." + f"{manip.name}: Message from unknown identifier(s) " + f"{', '.join(identifier_candidates)}." ) continue @@ -408,19 +463,6 @@ class HandleMessage(Command): ) session_cache[session_key] = chat_session - # Store each incoming/outgoing event once per session. - message_key = (chat_session.id, ts, source_uuid) - if message_key not in stored_messages: - log.info(f"Processing history store message {text}") - await history.store_message( - session=chat_session, - sender=source_uuid, - text=text, - ts=ts, - outgoing=is_from_bot, - ) - stored_messages.add(message_key) - # Get the total history chat_history = await history.get_chat_history(chat_session) @@ -493,9 +535,18 @@ class HandleMessage(Command): else: log.error(f"Mode {manip.mode} is not implemented") + chat_lookup = {"account": account} + if source_uuid: + chat_lookup["source_uuid"] = source_uuid + elif source_number: + chat_lookup["source_number"] = source_number + else: + return + await sync_to_async(Chat.objects.update_or_create)( - source_uuid=source_uuid, + **chat_lookup, defaults={ + "source_uuid": source_uuid, "source_number": source_number, "source_name": source_name, "account": account, diff --git a/core/clients/transport.py b/core/clients/transport.py index e8e439b..fda10dd 100644 --- a/core/clients/transport.py +++ b/core/clients/transport.py @@ -124,6 +124,22 @@ def get_service_warning(service: str) -> str: return "" +def request_pairing(service: str, device_name: str = ""): + """ + Mark a runtime pairing request so UR clients can refresh QR/pair state. + """ + service_key = _service_key(service) + if service_key not in {"whatsapp", "instagram"}: + return + device = str(device_name or "GIA Device").strip() or "GIA Device" + update_runtime_state( + service_key, + pair_device=device, + pair_requested_at=int(time.time()), + warning="Waiting for runtime pairing QR.", + ) + + async def _gateway_json(method: str, url: str, payload=None): timeout = aiohttp.ClientTimeout(total=20) async with aiohttp.ClientSession(timeout=timeout) as session: diff --git a/core/clients/whatsapp.py b/core/clients/whatsapp.py index e003ba6..b26f2b9 100644 --- a/core/clients/whatsapp.py +++ b/core/clients/whatsapp.py @@ -32,6 +32,7 @@ class WhatsAppClient(ClientBase): self._accounts = [] self._chat_presence = None self._chat_presence_media = None + self._last_pair_request = 0 self.enabled = bool( str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() @@ -120,8 +121,39 @@ class WhatsAppClient(ClientBase): # Keep task alive so state/callbacks remain active. while not self._stopping: + await self._sync_pair_request() await asyncio.sleep(1) + async def _sync_pair_request(self): + state = transport.get_runtime_state(self.service) + requested_at = int(state.get("pair_requested_at") or 0) + if requested_at <= 0 or requested_at <= self._last_pair_request: + return + self._last_pair_request = requested_at + self._publish_state( + connected=False, + pair_qr="", + warning="Waiting for WhatsApp QR from Neonize.", + ) + + if self._client is None: + return + + try: + if hasattr(self._client, "disconnect"): + await self._maybe_await(self._client.disconnect()) + except Exception as exc: + self.log.warning("whatsapp disconnect before pairing failed: %s", exc) + + try: + await self._maybe_await(self._client.connect()) + except Exception as exc: + self._publish_state( + connected=False, + warning=f"WhatsApp pairing refresh failed: {exc}", + ) + self.log.warning("whatsapp pairing refresh failed: %s", exc) + def _register_event(self, event_cls, callback): if event_cls is None: return diff --git a/core/templates/base.html b/core/templates/base.html index dcf74ae..e67c5a9 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -190,6 +190,26 @@ .modal-background{ background-color:rgba(255, 255, 255, 0.3) !important; } + #modals-here .modal-background { + background-color: rgba(0, 0, 0, 0.34) !important; + } + #modals-here .modal-content > .box { + background-color: rgba(255, 255, 255, 0.97) !important; + color: inherit; + } + #modals-here .modal-content .input, + #modals-here .modal-content .textarea, + #modals-here .modal-content .select select { + background-color: rgba(255, 255, 255, 0.98) !important; + } + [data-theme="dark"] #modals-here .modal-content > .box { + background-color: rgba(45, 45, 45, 0.97) !important; + } + [data-theme="dark"] #modals-here .modal-content .input, + [data-theme="dark"] #modals-here .modal-content .textarea, + [data-theme="dark"] #modals-here .modal-content .select select { + background-color: rgba(33, 33, 33, 0.98) !important; + } .has-background-grey-lighter{ background-color:rgba(219, 219, 219, 0.5) !important; @@ -340,25 +360,9 @@ Queue - + + OSINT + {% endif %} Install @@ -464,6 +468,54 @@ }); }); }); + + window.giaPrepareWidgetTarget = function () { + const target = document.getElementById("widgets-here"); + if (target) { + target.style.display = "block"; + } + }; + + window.giaCanSpawnWidgets = function () { + return !!( + window.grid && + typeof window.grid.addWidget === "function" && + document.getElementById("grid-stack-main") && + document.getElementById("widgets-here") + ); + }; + + window.giaEnableWidgetSpawnButtons = function (root) { + const scope = root && root.querySelectorAll ? root : document; + const canSpawn = window.giaCanSpawnWidgets(); + scope.querySelectorAll(".js-widget-spawn-trigger").forEach(function (button) { + const widgetUrl = String( + button.getAttribute("data-widget-url") + || button.getAttribute("hx-get") + || "" + ).trim(); + const visible = canSpawn && !!widgetUrl; + button.classList.toggle("is-hidden", !visible); + button.setAttribute("aria-hidden", visible ? "false" : "true"); + }); + }; + + document.addEventListener("click", function (event) { + const trigger = event.target.closest(".js-widget-spawn-trigger"); + if (!trigger) { + return; + } + window.giaPrepareWidgetTarget(); + }); + + document.addEventListener("DOMContentLoaded", function () { + window.giaEnableWidgetSpawnButtons(document); + }); + + document.body.addEventListener("htmx:afterSwap", function (event) { + const target = (event && event.target) || document; + window.giaEnableWidgetSpawnButtons(target); + }); {% block outer_content %} {% endblock %} diff --git a/core/templates/index.html b/core/templates/index.html index 28dae25..e63c09c 100644 --- a/core/templates/index.html +++ b/core/templates/index.html @@ -50,6 +50,9 @@ // re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid htmx.process(widgetelement); + if (typeof window.giaEnableWidgetSpawnButtons === "function") { + window.giaEnableWidgetSpawnButtons(widgetelement); + } // update the size of the widget according to its content var added_widget = htmx.find(grid_element, "#"+new_id); @@ -77,6 +80,9 @@ // container.inner = ""; // } grid.compact(); + if (typeof window.giaEnableWidgetSpawnButtons === "function") { + window.giaEnableWidgetSpawnButtons(document); + } });
diff --git a/core/templates/pages/osint-workspace.html b/core/templates/pages/osint-workspace.html new file mode 100644 index 0000000..58c8682 --- /dev/null +++ b/core/templates/pages/osint-workspace.html @@ -0,0 +1,11 @@ +{% extends "index.html" %} + +{% block load_widgets %} +
+{% endblock %} diff --git a/core/templates/partials/ai-workspace-mitigation-panel.html b/core/templates/partials/ai-workspace-mitigation-panel.html index 54999ca..ae13771 100644 --- a/core/templates/partials/ai-workspace-mitigation-panel.html +++ b/core/templates/partials/ai-workspace-mitigation-panel.html @@ -8,49 +8,64 @@ {% endif %}
- {{ plan.creation_mode|title }} / {{ plan.status|title }} - Created {{ plan.created_at }} - Updated {{ plan.updated_at }} - {% if plan.source_ai_result_id %} - Source Result {{ plan.source_ai_result_id }} - {% endif %}
-
-
- +
+

Plan Details

+
+
-
-
- -
-
-
-
-
- +

Created {{ plan.created_at }} · Updated {{ plan.updated_at }}

+

+ {{ plan.creation_mode|title }} / {{ plan.status|title }} + {% if plan.source_ai_result_id %} + · Source Result {{ plan.source_ai_result_id }} + {% endif %} +

+
+
+
+
-
-
- +
+
+
-
- +
+
+
+ +
+
+
+
+ +
+
@@ -263,73 +278,88 @@ {% if corrections %} {% for correction in corrections %} -
- Correction - Created {{ correction.created_at }} +
-
-
- -
-
- - -
-
- - -
-
- -
- -
-
-
- -
- -
-
-
- -
- -
+ +
+

{{ correction.title }}

+
+ + +
- - -
- - +

Created {{ correction.created_at }}

+

{{ correction.clarification }}

+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
@@ -511,18 +541,33 @@
+

+ Master gate. When off, automatic checks return early and no auto plan, correction, or notification actions run for this conversation. +

+

+ Controls background trigger behavior only. If disabled, auto-triggered scans are skipped; manual "Run Check Now" can still run. +

+

+ On AI pane load, if no plan exists and automation is enabled, a baseline plan is auto-created from recent messages. +

+

+ Writes up to 8 detected correction candidates per run, deduplicated by title + clarification, and links them to this plan. +

+

+ Sends a notification when violations are found (with count + top preview), using NTFY overrides if provided, otherwise default notifications. +

diff --git a/core/templates/partials/ai-workspace-person-widget.html b/core/templates/partials/ai-workspace-person-widget.html index 2a9fba3..f12841b 100644 --- a/core/templates/partials/ai-workspace-person-widget.html +++ b/core/templates/partials/ai-workspace-person-widget.html @@ -76,6 +76,21 @@ Manual Text Mode + {% if compose_widget_url %} + + {% endif %}
{% endif %}
@@ -113,37 +128,74 @@
- Mitigation + Control AI Output
@@ -208,14 +260,18 @@ display: flex; align-items: center; justify-content: center; - min-height: 2.55rem; + min-height: 2.2rem; + min-width: 2.2rem; + padding: 0 0.55rem; line-height: 1.15; text-align: center; - white-space: normal; + white-space: nowrap; } @media screen and (max-width: 768px) { .ai-person-widget .ai-top-tabs li a { - min-height: 2.8rem; + min-height: 2.1rem; + min-width: 2.1rem; + padding: 0 0.45rem; } } @@ -356,7 +412,7 @@ } const OPERATION_TABS = ["summarise", "draft_reply", "extract_patterns"]; - const MITIGATION_TABS = ["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"]; + const MITIGATION_TABS = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"]; const ALL_TOP_TABS = MITIGATION_TABS.concat(OPERATION_TABS); function isMitigationTab(tabKey) { @@ -483,6 +539,7 @@ if (cacheAllowed && !forceRefresh && entry) { pane.innerHTML = entry.html; pane.dataset.loaded = "1"; + executeInlineScripts(pane); pane.classList.remove("ai-animate-in"); void pane.offsetWidth; pane.classList.add("ai-animate-in"); @@ -649,7 +706,7 @@ if (typeof window.giaMitigationShowTab !== "function") { window.giaMitigationShowTab = function(pid, tabName) { - const names = ["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"]; + const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"]; names.forEach(function(name) { const pane = document.getElementById("mitigation-tab-" + pid + "-" + name); const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name); diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html index 7ef3863..7e64ec2 100644 --- a/core/templates/partials/compose-panel.html +++ b/core/templates/partials/compose-panel.html @@ -34,6 +34,21 @@ AI Workspace + {% if ai_workspace_widget_url %} + + {% endif %} {% if render_mode == "page" %} @@ -109,6 +124,32 @@
+
= (total - 1); + } + }; + const openLightboxAt = function (index) { + if (!lightbox || !lightboxImage) { + return; + } + const images = collectLightboxImages(); + if (!images.length) { + return; + } + const safeIndex = Math.max(0, Math.min(Number(index) || 0, images.length - 1)); + const imageNode = images[safeIndex]; + const source = String(imageNode.currentSrc || imageNode.src || "").trim(); + if (!source) { + return; + } + panelState.lightboxImages = images; + panelState.lightboxIndex = safeIndex; + lightboxImage.src = source; + lightbox.classList.remove("is-hidden"); + lightbox.setAttribute("aria-hidden", "false"); + syncLightboxNav(); + }; + const openLightboxFromElement = function (imageNode) { + const images = collectLightboxImages(); + if (!images.length) { + return; + } + const idx = images.indexOf(imageNode); + openLightboxAt(idx >= 0 ? idx : 0); + }; + const stepLightbox = function (delta) { + if (!lightbox || lightbox.classList.contains("is-hidden")) { + return; + } + if (!panelState.lightboxImages.length) { + panelState.lightboxImages = collectLightboxImages(); + } + if (!panelState.lightboxImages.length) { + return; + } + openLightboxAt(panelState.lightboxIndex + delta); + }; + const closeLightbox = function () { + if (!lightbox) { + return; + } + lightbox.classList.add("is-hidden"); + lightbox.setAttribute("aria-hidden", "true"); + if (lightboxImage) { + lightboxImage.removeAttribute("src"); + } + panelState.lightboxImages = []; + panelState.lightboxIndex = -1; + syncLightboxNav(); + }; + const openLightbox = function (srcValue) { + const source = String(srcValue || "").trim(); + if (!source) { + return; + } + const images = collectLightboxImages(); + const idx = images.findIndex(function (img) { + return String(img.currentSrc || img.src || "").trim() === source; + }); + openLightboxAt(idx >= 0 ? idx : 0); + }; let lastTs = toInt(thread.dataset.lastTs); let glanceState = { @@ -1017,6 +1254,27 @@ if (!scope) { return; } + scope.querySelectorAll(".compose-image").forEach(function (img) { + if (img.dataset.lightboxBound === "1") { + return; + } + img.dataset.lightboxBound = "1"; + img.setAttribute("role", "button"); + img.setAttribute("tabindex", "0"); + img.setAttribute("aria-label", "Open image preview"); + img.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + openLightboxFromElement(img); + }); + img.addEventListener("keydown", function (event) { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + openLightboxFromElement(img); + }); + }); scope.querySelectorAll(".compose-bubble").forEach(function (bubble) { const fallback = bubble.querySelector(".compose-image-fallback"); const refresh = function () { @@ -1642,6 +1900,36 @@ const docs = Array.isArray(payload.docs) ? payload.docs : []; container.innerHTML = ""; + const docsTooltip = function (title, calculation, psychology) { + const parts = []; + if (calculation) { + parts.push("How it is calculated: " + String(calculation || "")); + } + if (psychology) { + parts.push("Psychological interpretation: " + String(psychology || "")); + } + if (!parts.length) { + return ""; + } + return String(title || "Metric") + " | " + parts.join(" | "); + }; + + const appendDocDot = function (target, tooltipText, titleText) { + if (!target || !tooltipText) { + return; + } + const dot = document.createElement("button"); + dot.type = "button"; + dot.className = "compose-qi-doc-dot"; + dot.title = String(tooltipText || ""); + dot.setAttribute("aria-label", "Explain " + String(titleText || "metric")); + dot.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + }); + target.appendChild(dot); + }; + const stateFaceMeta = function (stateText) { const state = String(stateText || "").toLowerCase(); if (state.includes("balanced")) { @@ -1697,28 +1985,67 @@ const head = document.createElement("div"); head.className = "compose-qi-head"; [ - { key: "Platform", value: summary.platform || "-" }, + { + key: "Platform", + value: summary.platform || "-", + docs: summary.platform_docs || {}, + }, { key: "Participant State", value: summary.state || "-", icon: stateFace.icon, className: stateFace.className, + docs: summary.state_docs || {}, + }, + { + key: "Data Points", + value: String(summary.snapshot_count || 0), + docs: summary.snapshot_docs || {}, + }, + { + key: "Thread", + value: summary.thread || "-", + docs: summary.thread_docs || {}, }, - { key: "Data Points", value: String(summary.snapshot_count || 0) }, - { key: "Thread", value: summary.thread || "-" }, ].forEach(function (pair) { const chip = document.createElement("div"); chip.className = "compose-qi-chip"; - let valueHtml = String(pair.value || "-"); + + const key = document.createElement("p"); + key.className = "k"; + const keyText = document.createElement("span"); + keyText.textContent = String(pair.key || ""); + key.appendChild(keyText); + const pairDocs = pair.docs || {}; + appendDocDot( + key, + docsTooltip( + pair.key, + pairDocs.calculation, + pairDocs.psychology + ), + pair.key + ); + chip.appendChild(key); + + const value = document.createElement("p"); + value.className = "v"; if (pair.icon) { - valueHtml = ( - '' - + '' - + "" - + "" + valueHtml + "" - ); + const iconWrap = document.createElement("span"); + iconWrap.className = String(pair.className || ""); + const icon = document.createElement("span"); + icon.className = "icon is-small"; + const glyph = document.createElement("i"); + glyph.className = String(pair.icon || ""); + icon.appendChild(glyph); + iconWrap.appendChild(icon); + value.appendChild(iconWrap); } - chip.innerHTML = '

' + pair.key + "

" + '

' + valueHtml + "

"; + const valueText = document.createElement("span"); + valueText.textContent = String(pair.value || "-"); + value.appendChild(valueText); + chip.appendChild(value); + head.appendChild(chip); }); container.appendChild(head); @@ -1734,24 +2061,72 @@ rows.forEach(function (row) { const node = document.createElement("article"); node.className = "compose-qi-row"; - node.innerHTML = ( - '
' - + '

' - + String(row.label || "") + "

" - + '

' + String(row.point_count || 0) - + ' points ' + String(row.delta_label || "n/a") - + "

" - + '
' - + '

' + String(row.display_value || "-") + "

" - + '

' - + ' ' + String(((row.emotion || {}).label) || "Unknown") - + "

" + + const rowHead = document.createElement("div"); + rowHead.className = "compose-qi-row-head"; + + const rowLabel = document.createElement("p"); + rowLabel.className = "compose-qi-row-label"; + const rowIcon = document.createElement("span"); + rowIcon.className = "icon is-small"; + const rowIconGlyph = document.createElement("i"); + rowIconGlyph.className = String(row.icon || "fa-solid fa-square"); + rowIcon.appendChild(rowIconGlyph); + rowLabel.appendChild(rowIcon); + const rowLabelText = document.createElement("span"); + rowLabelText.textContent = String(row.label || ""); + rowLabel.appendChild(rowLabelText); + appendDocDot( + rowLabel, + docsTooltip(row.label, row.calculation, row.psychology), + row.label ); + rowHead.appendChild(rowLabel); + + const rowMeta = document.createElement("p"); + rowMeta.className = "compose-qi-row-meta"; + const points = document.createElement("span"); + points.textContent = String(row.point_count || 0) + " points"; + rowMeta.appendChild(points); + const trend = row.trend || {}; + const trendNode = document.createElement("span"); + trendNode.className = String(trend.class_name || ""); + const trendIcon = document.createElement("span"); + trendIcon.className = "icon is-small"; + const trendGlyph = document.createElement("i"); + trendGlyph.className = String(trend.icon || ""); + trendIcon.appendChild(trendGlyph); + trendNode.appendChild(trendIcon); + const trendText = document.createTextNode(" " + String(row.delta_label || "n/a")); + trendNode.appendChild(trendText); + rowMeta.appendChild(trendNode); + rowHead.appendChild(rowMeta); + node.appendChild(rowHead); + + const rowBody = document.createElement("div"); + rowBody.className = "compose-qi-row-body"; + const rowValue = document.createElement("p"); + rowValue.className = "compose-qi-value"; + rowValue.textContent = String(row.display_value || "-"); + rowBody.appendChild(rowValue); + + const emotion = row.emotion || {}; + const emotionNode = document.createElement("p"); + emotionNode.className = String(emotion.class_name || ""); + emotionNode.style.margin = "0"; + emotionNode.style.fontSize = "0.72rem"; + const emotionIconWrap = document.createElement("span"); + emotionIconWrap.className = "icon is-small"; + const emotionGlyph = document.createElement("i"); + emotionGlyph.className = String(emotion.icon || ""); + emotionIconWrap.appendChild(emotionGlyph); + emotionNode.appendChild(emotionIconWrap); + emotionNode.appendChild( + document.createTextNode(" " + String(emotion.label || "Unknown")) + ); + rowBody.appendChild(emotionNode); + node.appendChild(rowBody); + list.appendChild(node); }); container.appendChild(list); @@ -1991,6 +2366,53 @@ hideAllCards(); }); } + if (lightbox) { + if (lightboxPrev) { + lightboxPrev.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + stepLightbox(-1); + }); + } + if (lightboxNext) { + lightboxNext.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + stepLightbox(1); + }); + } + const closeButton = lightbox.querySelector(".compose-lightbox-close"); + if (closeButton) { + closeButton.addEventListener("click", function (event) { + event.preventDefault(); + closeLightbox(); + }); + } + lightbox.addEventListener("click", function (event) { + if (event.target === lightbox) { + closeLightbox(); + } + }); + panelState.lightboxKeyHandler = function (event) { + if (lightbox.classList.contains("is-hidden")) { + return; + } + if (event.key === "Escape") { + closeLightbox(); + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + stepLightbox(-1); + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + stepLightbox(1); + } + }; + document.addEventListener("keydown", panelState.lightboxKeyHandler); + } panelState.resizeHandler = function () { if (!popover || popover.classList.contains("is-hidden")) { return; @@ -2056,6 +2478,9 @@ document.body.removeEventListener("composeMessageSent", panelState.eventHandler); document.body.removeEventListener("composeSendResult", panelState.sendResultHandler); document.removeEventListener("mousedown", panelState.docClickHandler); + if (panelState.lightboxKeyHandler) { + document.removeEventListener("keydown", panelState.lightboxKeyHandler); + } if (panelState.socket) { try { panelState.socket.close(); @@ -2063,6 +2488,9 @@ // Ignore. } } + if (lightbox && lightbox.parentElement === document.body) { + lightbox.remove(); + } delete window.giaComposePanels[panelId]; return; } diff --git a/core/templates/partials/osint-workspace-tabs-widget.html b/core/templates/partials/osint-workspace-tabs-widget.html new file mode 100644 index 0000000..7c419f1 --- /dev/null +++ b/core/templates/partials/osint-workspace-tabs-widget.html @@ -0,0 +1,71 @@ +
+

+ OSINT Workspace +

+

+ One-line setup capsule. Each tab opens a fresh setup widget. +

+ +
+ {% for tab in tabs %} + + {% endfor %} +
+
+ + diff --git a/core/templates/partials/osint/list-table.html b/core/templates/partials/osint/list-table.html index d89123b..ce090f0 100644 --- a/core/templates/partials/osint/list-table.html +++ b/core/templates/partials/osint/list-table.html @@ -70,6 +70,7 @@ onclick="return false;"> {{ column.label }} + ({{ column.field_name }})
{% endfor %}
@@ -284,11 +285,18 @@