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 @@