Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions DOCS/features/operation-logs/create-a-new-entry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Tip>
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`.
</Tip>

### 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file modified DOCS/sample_reports/template.docx
Binary file not shown.
4 changes: 2 additions & 2 deletions VERSION
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
v7.2.3
18 July 2026
v7.2.4
21 July 2026
4 changes: 2 additions & 2 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

{% block admin_change_form_document_ready %}
{{ block.super }}
{% include "snippets/editor_shortcuts_config.html" %}
{% if original.is_collab_editable %}
<script type="module" src="{% static 'assets/admin_tiptap.js' %}"></script>
<link rel="stylesheet" href="{% static 'assets/collab_common.css' %}">
Expand Down Expand Up @@ -101,4 +102,4 @@
});
});
</script>
{% endblock %}
{% endblock %}
8 changes: 8 additions & 0 deletions ghostwriter/context_processors.py
Original file line number Diff line number Diff line change
@@ -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
),
}
32 changes: 32 additions & 0 deletions ghostwriter/home/editor_shortcuts.py
Original file line number Diff line number Diff line change
@@ -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"),
}
57 changes: 55 additions & 2 deletions ghostwriter/home/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down
5 changes: 5 additions & 0 deletions ghostwriter/home/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions ghostwriter/home/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions ghostwriter/modules/reportwriter/base/html_rich_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions ghostwriter/modules/reportwriter/richtext/ooxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion ghostwriter/modules/reportwriter/richtext/plain_text.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions ghostwriter/oplog/templates/oplog/oplog_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ <h5 class="modal-title" id="oplogHelpModalLabel"><i class="fas fa-book-open mr-2
<li><strong>&uarr;&darr; arrow keys</strong> move between entries once one is selected.</li>
<li><kbd>Enter</kbd> opens the selected entry's edit form.</li>
<li><kbd>Ctrl+Enter</kbd> or <kbd>Cmd+Enter</kbd> submits the edit form, saving the entry and closing the modal.</li>
<li>Typing <code>@today</code>/<code>@date</code> or <code>@now</code>/<code>@time</code> followed by a space or punctuation in a rich text field inserts the current date or time.</li>
<li><kbd>Esc</kbd> closes the details pane and deselects the entry, giving the table full width.</li>
<li><kbd>Ctrl+N</kbd> creates a new entry.</li>
</ul>
Expand Down
21 changes: 21 additions & 0 deletions ghostwriter/oplog/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,27 @@ def test_view_exposes_active_time_zone(self):
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'data-time-zone="America/Los_Angeles"')

@override_settings(DATE_FORMAT="Y/m/d")
@patch(
"ghostwriter.context_processors.get_editor_shortcuts_date_config",
return_value={
"date": "2026/07/21",
"expiresAt": 1784707200000,
"serverTime": 1784678400000,
"refreshUrl": "/ajax/editor-shortcuts/date",
},
)
def test_view_documents_date_time_shortcuts(self, _mock_date_config):
response = self.client_mgr.get(self.uri)

self.assertEqual(response.status_code, 200)
self.assertContains(response, "@now")
self.assertContains(response, "@time")
self.assertContains(response, "@today")
self.assertContains(response, "@date")
self.assertContains(response, 'id="gw-current-date"')
self.assertContains(response, '"2026/07/21"')

def test_view_displays_last_sanitization(self):
OplogSanitization.objects.create(
oplog=self.oplog,
Expand Down
33 changes: 33 additions & 0 deletions ghostwriter/reporting/tests/test_rich_text_docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,39 @@ def test_func(self):
class RichTextToDocxTests(SimpleTestCase):
maxDiff = None

def test_trailing_empty_paragraph_after_list_is_omitted(self):
doc = docx.Document()
HtmlToDocx.run(
"<ul><li><p>dc01.gbi.local</p></li>"
"<li><p>fs01.gbi.local</p></li>"
"<li><p>portal.gbi.example</p></li></ul><p></p><p></p>",
doc,
None,
)

self.assertEqual(
[paragraph.text for paragraph in doc.paragraphs],
["dc01.gbi.local", "fs01.gbi.local", "portal.gbi.example"],
)

def test_internal_empty_paragraph_is_preserved(self):
doc = docx.Document()
HtmlToDocx.run("<p>Before</p><p></p><p>After</p>", doc, None)

self.assertEqual(
[paragraph.text for paragraph in doc.paragraphs],
["Before", "", "After"],
)

def test_trailing_hard_break_is_preserved(self):
doc = docx.Document()
HtmlToDocx.run("<p>Before</p><p><br></p>", doc, None)

self.assertEqual(
[paragraph.text for paragraph in doc.paragraphs],
["Before", "\n"],
)

def test_table_cell_paragraphs_remain_in_the_cell(self):
doc = docx.Document()
HtmlToDocx.run(
Expand Down
Loading
Loading