diff --git a/CHANGELOG.md b/CHANGELOG.md index 0adf7ce22..645f63b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.2.4] - 21 July 2026 + +### Added + +* Added shortcuts to the editors for easily inserting dates and times + * `@now` / `@time` inserts `HH:mm:ss UTC` + * `@today` / `@date` inserts the date using Django’s configured `DATE_FORMAT` + * Spaces and unicode punctuation trigger the expansion + * There are guards in place so code blocks and email-like strings do not trigger expansion + * If an editor is left open overnight, there is a trigger to refresh the date + +### Fixed + +* Fixed an issue where a blank line would be included after lists in report output + ## [7.2.3] - 18 July 2026 ### Added diff --git a/DOCS/features/operation-logs/create-a-new-entry.mdx b/DOCS/features/operation-logs/create-a-new-entry.mdx index 0cf0a76fc..dc7e1573a 100644 --- a/DOCS/features/operation-logs/create-a-new-entry.mdx +++ b/DOCS/features/operation-logs/create-a-new-entry.mdx @@ -20,6 +20,11 @@ To manually create an entry, click on the "Create a new entry" button in the act You see a new row appear pre-populated with the current UTC timestamps and your username in the _Operator_ field. + +When writing in a rich text field, type `@today` or `@date` followed by a space or punctuation to insert the current UTC date using +Ghostwriter's configured date format. Type `@now` or `@time` the same way to insert the current time in UTC, such as `20:14:13 UTC`. + + ### Modifying an Entry You can modify fields by double-clicking the table row you want to edit or pressing _Enter_ while it is selected. A modal form will open: diff --git a/DOCS/features/reporting/collaborative-editor/editor-features.mdx b/DOCS/features/reporting/collaborative-editor/editor-features.mdx index 42283bb1b..932f36b52 100644 --- a/DOCS/features/reporting/collaborative-editor/editor-features.mdx +++ b/DOCS/features/reporting/collaborative-editor/editor-features.mdx @@ -5,6 +5,13 @@ description: "Features of the collaborative editor" Collaborative editing is the star of the show, but the editor contains other features worth exploring. +## Inserting the Current Date and Time + +Type `@today` or its alias `@date` followed by a space or punctuation to insert the current UTC date using Ghostwriter's configured date +format. For example, with the default format, `@today,` becomes `21 Jul 2026,`. Type `@now` or its alias `@time` the same way to insert +the current time in UTC, such as `20:14:13 UTC`. The inserted values are ordinary text and do not change afterward. The shortcuts are +disabled inside inline code and code blocks. + ## Inserting Objects The editor supports inserting objects that make it easier to visualize certain elements of your writing, such as captions and evidence files. diff --git a/DOCS/sample_reports/template.docx b/DOCS/sample_reports/template.docx index 52ddc15ea..56986c02e 100644 Binary files a/DOCS/sample_reports/template.docx and b/DOCS/sample_reports/template.docx differ diff --git a/VERSION b/VERSION index 6ea2e395e..0852c00de 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ -v7.2.3 -18 July 2026 +v7.2.4 +21 July 2026 diff --git a/config/settings/base.py b/config/settings/base.py index 380625f0e..6240bde31 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -11,9 +11,9 @@ # 3rd Party Libraries import environ -__version__ = "7.2.3" +__version__ = "7.2.4" VERSION = __version__ -RELEASE_DATE = "18 July 2026" +RELEASE_DATE = "21 July 2026" ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent APPS_DIR = ROOT_DIR / "ghostwriter" diff --git a/ghostwriter/commandcenter/templates/user_extra_fields/admin_change_form.html b/ghostwriter/commandcenter/templates/user_extra_fields/admin_change_form.html index 3b2b311bb..e162ed314 100644 --- a/ghostwriter/commandcenter/templates/user_extra_fields/admin_change_form.html +++ b/ghostwriter/commandcenter/templates/user_extra_fields/admin_change_form.html @@ -3,6 +3,7 @@ {% block admin_change_form_document_ready %} {{ block.super }} +{% include "snippets/editor_shortcuts_config.html" %} {% if original.is_collab_editable %} @@ -101,4 +102,4 @@ }); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/ghostwriter/context_processors.py b/ghostwriter/context_processors.py index 953685456..a6dba9dcb 100644 --- a/ghostwriter/context_processors.py +++ b/ghostwriter/context_processors.py @@ -1,9 +1,17 @@ # Django Imports from django.conf import settings +# Ghostwriter Libraries +from ghostwriter.home.editor_shortcuts import get_editor_shortcuts_date_config + def selected_settings(request): return { "VERSION": settings.VERSION, "RELEASE_DATE": settings.RELEASE_DATE, + "EDITOR_SHORTCUTS_DATE_CONFIG": ( + get_editor_shortcuts_date_config() + if request.user.is_authenticated + else None + ), } diff --git a/ghostwriter/home/editor_shortcuts.py b/ghostwriter/home/editor_shortcuts.py new file mode 100644 index 000000000..e7464cb73 --- /dev/null +++ b/ghostwriter/home/editor_shortcuts.py @@ -0,0 +1,32 @@ +"""Server-provided configuration for rich-text editor date shortcuts.""" + +# Standard Libraries +from datetime import datetime, time, timedelta, timezone as datetime_timezone + +# Django Imports +from django.conf import settings +from django.urls import reverse +from django.utils import dateformat, timezone + + +def _current_utc_time(): + """Return the current time normalized to UTC.""" + return timezone.now().astimezone(datetime_timezone.utc) + + +def get_editor_shortcuts_date_config(): + """Return the formatted current UTC date and its next UTC rollover.""" + current_time = _current_utc_time() + current_date = current_time.date() + next_midnight = datetime.combine( + current_date + timedelta(days=1), + time.min, + tzinfo=datetime_timezone.utc, + ) + + return { + "date": dateformat.format(current_date, settings.DATE_FORMAT), + "expiresAt": round(next_midnight.timestamp() * 1000), + "serverTime": round(current_time.timestamp() * 1000), + "refreshUrl": reverse("home:ajax_editor_shortcuts_date"), + } diff --git a/ghostwriter/home/tests/test_views.py b/ghostwriter/home/tests/test_views.py index 3da594de5..f10f950fc 100644 --- a/ghostwriter/home/tests/test_views.py +++ b/ghostwriter/home/tests/test_views.py @@ -1,15 +1,18 @@ # Standard Libraries import logging -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone as datetime_timezone from io import StringIO +from unittest.mock import patch +from zoneinfo import ZoneInfo # Django Imports from django.conf import settings from django.core.management import call_command from django.core.management.base import CommandError from django.db.models import Q -from django.test import Client, TestCase +from django.test import Client, TestCase, override_settings from django.urls import reverse +from django.utils import timezone # 3rd Party Libraries from allauth.mfa.totp.internal.auth import generate_totp_secret, TOTP @@ -35,6 +38,56 @@ PASSWORD = "SuperNaturalReporting!" +class EditorShortcutsDateTests(TestCase): + """Tests for refreshing server-formatted editor shortcut dates.""" + + @classmethod + def setUpTestData(cls): + cls.user = UserFactory(password=PASSWORD) + cls.uri = reverse("home:ajax_editor_shortcuts_date") + + def setUp(self): + self.client = Client() + self.client_auth = Client() + self.assertTrue( + self.client_auth.login(username=self.user.username, password=PASSWORD) + ) + + def test_view_requires_login(self): + response = self.client.get(self.uri) + + self.assertEqual(response.status_code, 302) + + @override_settings(DATE_FORMAT="Y/m/d") + @patch("ghostwriter.home.editor_shortcuts._current_utc_time") + def test_view_returns_date_and_next_utc_midnight(self, mock_current_utc_time): + local_timezone = ZoneInfo("America/Los_Angeles") + local_time = datetime(2026, 7, 21, 23, 59, 30, tzinfo=local_timezone) + current_time = local_time.astimezone(datetime_timezone.utc) + mock_current_utc_time.return_value = current_time + + with timezone.override(local_timezone): + response = self.client_auth.get(self.uri) + + next_midnight = datetime(2026, 7, 23, tzinfo=datetime_timezone.utc) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "date": "2026/07/22", + "expiresAt": round(next_midnight.timestamp() * 1000), + "serverTime": round(current_time.timestamp() * 1000), + "refreshUrl": self.uri, + }, + ) + self.assertIn("no-store", response.headers["Cache-Control"]) + + def test_view_rejects_post(self): + response = self.client_auth.post(self.uri) + + self.assertEqual(response.status_code, 405) + + # Tests related to custom management commands diff --git a/ghostwriter/home/urls.py b/ghostwriter/home/urls.py index 8f68f6ce9..772bce038 100644 --- a/ghostwriter/home/urls.py +++ b/ghostwriter/home/urls.py @@ -17,6 +17,11 @@ # URLs for AJAX test functions urlpatterns += [ + path( + "ajax/editor-shortcuts/date", + views.editor_shortcuts_date, + name="ajax_editor_shortcuts_date", + ), path( "ajax/management/test/aws", views.TestAWSConnection.as_view(), diff --git a/ghostwriter/home/views.py b/ghostwriter/home/views.py index de56582e0..b75850239 100644 --- a/ghostwriter/home/views.py +++ b/ghostwriter/home/views.py @@ -12,6 +12,8 @@ from django.db.models import Prefetch, Q from django.http import HttpResponseNotAllowed, JsonResponse from django.shortcuts import redirect, render +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_GET from django.views.generic.edit import View # 3rd Party Libraries @@ -20,6 +22,7 @@ # Ghostwriter Libraries from ghostwriter.api.utils import RoleBasedAccessControlMixin, get_project_list, verify_user_is_privileged +from ghostwriter.home.editor_shortcuts import get_editor_shortcuts_date_config from ghostwriter.modules.health_utils import DjangoHealthChecks from ghostwriter.reporting.models import ReportFindingLink, ReportObservationLink from ghostwriter.rolodex.models import ProjectAssignment @@ -30,6 +33,14 @@ logger = logging.getLogger(__name__) +@login_required +@require_GET +@never_cache +def editor_shortcuts_date(request): + """Return the current server-formatted date for long-lived editor pages.""" + return JsonResponse(get_editor_shortcuts_date_config()) + + def _format_assignment_operator(assignment): if not assignment.operator: return "Unassigned" diff --git a/ghostwriter/modules/reportwriter/base/html_rich_text.py b/ghostwriter/modules/reportwriter/base/html_rich_text.py index 863331518..edd2352c9 100644 --- a/ghostwriter/modules/reportwriter/base/html_rich_text.py +++ b/ghostwriter/modules/reportwriter/base/html_rich_text.py @@ -11,6 +11,24 @@ _H = [f"h{n}" for n in range(1, 7)] + +def remove_trailing_empty_paragraphs(body): + """Remove editor-only trailing paragraphs from parsed rich text.""" + for child in reversed(list(body.children)): + if isinstance(child, bs4.NavigableString): + if child.strip(): + break + continue + if ( + child.name == "p" + and not child.get_text(strip=True) + and child.find(True) is None + ): + child.extract() + continue + break + + def rich_text_template( env: jinja2.Environment, text: str, diff --git a/ghostwriter/modules/reportwriter/richtext/ooxml.py b/ghostwriter/modules/reportwriter/richtext/ooxml.py index a108e811a..0f35db96c 100644 --- a/ghostwriter/modules/reportwriter/richtext/ooxml.py +++ b/ghostwriter/modules/reportwriter/richtext/ooxml.py @@ -6,6 +6,11 @@ # 3rd Party Libraries import bs4 +# Ghostwriter Libraries +from ghostwriter.modules.reportwriter.base.html_rich_text import ( + remove_trailing_empty_paragraphs, +) + logger = logging.getLogger(__name__) ALLOWED_HYPERLINK_PROTOCOLS = {"http", "https", "mailto", "tel"} @@ -111,6 +116,7 @@ def run(cls, text: str, *args, **kwargs): tag = soup.find("body") instance = cls(*args, **kwargs) if tag is not None: + remove_trailing_empty_paragraphs(tag) instance.process_children(tag.children) return instance diff --git a/ghostwriter/modules/reportwriter/richtext/plain_text.py b/ghostwriter/modules/reportwriter/richtext/plain_text.py index 163c6e995..736bf3650 100644 --- a/ghostwriter/modules/reportwriter/richtext/plain_text.py +++ b/ghostwriter/modules/reportwriter/richtext/plain_text.py @@ -1,6 +1,13 @@ -import bs4 +# Standard Libraries from io import StringIO +# 3rd Party Libraries +import bs4 + +# Ghostwriter Libraries +from ghostwriter.modules.reportwriter.base.html_rich_text import ( + remove_trailing_empty_paragraphs, +) from ghostwriter.modules.reportwriter.richtext.ooxml import strip_text_whitespace @@ -9,6 +16,7 @@ def html_to_plain_text(text: str, evidences) -> str: tag = soup.find("body") if tag is None: return "" + remove_trailing_empty_paragraphs(tag) out = StringIO() _build_html_str(tag, evidences, out) diff --git a/ghostwriter/oplog/templates/oplog/oplog_detail.html b/ghostwriter/oplog/templates/oplog/oplog_detail.html index bc3b10469..8c49cc9f1 100644 --- a/ghostwriter/oplog/templates/oplog/oplog_detail.html +++ b/ghostwriter/oplog/templates/oplog/oplog_detail.html @@ -303,6 +303,7 @@