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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Bug Report
description: Something isn't working the way it should
labels: ["bug"]
labels: ["bug", "needs triage"]
body:
- type: markdown
attributes:
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Feature Request
description: Suggest an idea or improvement for Grimoire
labels: ["enhancement"]
labels: ["enhancement", "needs triage"]
body:
- type: markdown
attributes:
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/question.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Question / Help
description: Not sure how something works, or need help getting set up?
labels: ["question"]
labels: ["question", "needs triage"]
body:
- type: markdown
attributes:
Expand Down
175 changes: 124 additions & 51 deletions backend/indexer.py

Large diffs are not rendered by default.

89 changes: 87 additions & 2 deletions backend/routers/campaigns/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,97 @@ def delete_guest_user(db, user_id: str) -> None:
def strip_gm_secrets(body: str) -> str:
"""Remove every ||...|| GM-only span (markers and enclosed text) from a body.

Used before sending a wiki page to a non-owner so the GM's hidden notes never
leave the server. An unterminated trailing `||` (no closing pair) is left as-is.
This is what a non-owner receives: the hidden text is gone without a trace —
no placeholder, no marker — so a player never learns a secret exists or where
it sits, whether in the rendered page, the raw editor body, or a search
snippet. An unterminated trailing ``||`` (no closing pair) is left as-is.

A non-owner edits this stripped body; on save ``merge_gm_secrets`` re-weaves
the stored secrets back in by matching the surrounding text. See
[[merge_gm_secrets]].
"""
return _GM_SECRET_RE.sub("", body or "")


def _secret_positions(stored: str):
"""Return [(secret_text, offset_in_stripped)] for each ||...|| in ``stored``.

``offset_in_stripped`` is where the secret sat within
``strip_gm_secrets(stored)`` — the boundary in the visible text the player last
saw. It is the secret's start minus the length of all secrets before it (those
chars aren't in the stripped text). Adjacent secrets legitimately share one
boundary; both are re-inserted there in document order.
"""
out = []
removed = 0 # total chars of earlier secrets, absent from the stripped text
for m in _GM_SECRET_RE.finditer(stored):
out.append((m.group(0), m.start() - removed))
removed += m.end() - m.start()
return out


def merge_gm_secrets(stored_body: str, new_body: str) -> str:
"""Re-weave the stored body's ||...|| GM secrets into a non-owner's saved edit.

A non-owner never receives the secrets: they edit ``strip_gm_secrets(stored)``,
a body with no marker or trace of the hidden text. So ``new_body`` has none,
and storing it verbatim would delete every secret. This restores them by
position:

* ``old_visible`` — the exact stripped text the player was shown — is aligned
against ``new_body`` (a character diff). Each secret sat at a known boundary
in ``old_visible``; that boundary is mapped forward through the diff to the
corresponding spot in ``new_body``, and the secret is re-inserted there.
* This holds the secret in place when the player edits the text above and/or
below it — the secret does not drift past later paragraphs.
* If the text on *both* sides of a secret's boundary was rewritten (the anchor
is gone), the secret is appended at the end so it is preserved, never lost.

``new_body`` is returned unchanged when the stored body had no secrets.
"""
from difflib import SequenceMatcher

stored = stored_body or ""
positioned = _secret_positions(stored)
if not positioned:
return new_body

new = new_body if new_body is not None else ""
old_visible = strip_gm_secrets(stored)

# Map each offset in old_visible to an offset in new via the diff's matching
# blocks. A boundary that falls inside an unchanged run maps exactly; one that
# falls in a replaced/deleted region has no stable image → treat as lost.
sm = SequenceMatcher(None, old_visible, new, autojunk=False)
blocks = sm.get_matching_blocks() # includes the terminating (len, len, 0)

def map_offset(old_off):
for b in blocks:
if b.a <= old_off <= b.a + b.size:
return b.b + (old_off - b.a)
return None

# Insert secrets from last to first so earlier insertions don't shift the
# offsets of later ones. Ties (adjacent secrets at one boundary) are broken by
# document index so their original order is preserved after insertion.
indexed = [(off, i, secret) for i, (secret, off) in enumerate(positioned)]
result = new
orphans = []
for old_off, i, secret in sorted(indexed, reverse=True):
pos = map_offset(old_off)
if pos is None:
orphans.append((i, secret))
else:
result = result[:pos] + secret + result[pos:]

if orphans:
# Restore document order for the appended, position-lost secrets.
orphans.sort()
sep = "" if not result or result.endswith("\n") else "\n"
result = result + sep + "\n".join(s for _, s in orphans)
return result


def is_gm_or_admin(user: CurrentUser) -> bool:
return user.role in ("admin", "gm")

Expand Down
17 changes: 13 additions & 4 deletions backend/routers/campaigns/wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
can_view,
extract_snippet,
get_campaign_or_404,
merge_gm_secrets,
strip_gm_secrets,
)
from ._schemas import WikiPageCreate, WikiPageUpdate, WikiReorder
Expand Down Expand Up @@ -252,9 +253,12 @@ def get_page(
"campaign_id": campaign_id,
"title": page.title,
"slug": page.slug,
# ||...|| spans are GM-only: strip them for everyone but the owner.
# (Personal campaigns only ever have the owner as a viewer, so their
# bodies are never stripped.)
# ||...|| spans are GM-only. The owner gets the raw body; everyone else
# gets it fully stripped — no secret text and no marker, so a player
# never learns a secret exists or where. A later save re-weaves the
# stored secrets back by position (merge_gm_secrets). (Personal
# campaigns only ever have the owner as a viewer, so nothing is
# stripped.)
"body": page.body if is_owner else strip_gm_secrets(page.body),
"visibility": page.visibility,
"page_type": page.page_type,
Expand Down Expand Up @@ -355,7 +359,12 @@ def update_page(
page.title = new_title
page.slug = _ensure_unique_slug(db, campaign_id, slugify(new_title), exclude_id=page.id)
if data.body is not None:
page.body = data.body
# A non-owner never received the ||...|| GM secrets (they're stripped
# on read), so their submitted body has none — storing it verbatim
# would delete the GM's hidden notes. Re-inject the stored secrets so
# they outlive a player's edit. The owner submits the full body, secrets
# and all, so nothing is merged for them.
page.body = data.body if is_owner else merge_gm_secrets(page.body, data.body)
if data.visibility is not None:
if data.visibility not in ("gm", "group", "members"):
raise HTTPException(400, "Invalid visibility")
Expand Down
98 changes: 98 additions & 0 deletions backend/tests/test_campaign_schedule_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from backend.routers.campaigns._helpers import (
compute_next_sessions,
merge_gm_secrets,
nth_weekday_of_month,
extract_snippet,
strip_gm_secrets,
Expand Down Expand Up @@ -130,3 +131,100 @@ def test_leaves_unterminated_marker(self):

def test_none_body_returns_empty(self):
assert strip_gm_secrets(None) == ""


class TestMergeGmSecrets:
"""merge_gm_secrets re-weaves stored ||secrets|| into a body the player edited.

A non-owner edits ``strip_gm_secrets(stored)`` — a fully clean body with no
secret text and no marker of any kind. So every ``new`` here is built by
editing that stripped view, mirroring what the player actually sends back.
"""

def _seen(self, stored):
"""The clean body the player was shown (what they edit)."""
return strip_gm_secrets(stored)

def test_no_stored_secrets_returns_new_body_verbatim(self):
assert merge_gm_secrets("plain stored", "player edit") == "player edit"

def test_unedited_clean_body_restores_secret_in_place(self):
stored = "Para A. ||secret|| Para B."
# Player saved without touching anything.
assert merge_gm_secrets(stored, self._seen(stored)) == stored

def test_edit_above_secret_keeps_it_in_place(self):
stored = "Para A. ||secret|| Para B."
new = self._seen(stored).replace("Para A.", "Para A edited.")
assert merge_gm_secrets(stored, new) == "Para A edited. ||secret|| Para B."

def test_edit_below_secret_keeps_it_in_place(self):
stored = "Para A. ||secret|| Para B."
new = self._seen(stored).replace("Para B.", "Para B edited.")
assert merge_gm_secrets(stored, new) == "Para A. ||secret|| Para B edited."

def test_edit_both_sides_keeps_secret_between_them(self):
# The reporter's 3-paragraph case: the secret must NOT drift below the
# second block when the text above AND below it is edited.
stored = "Para A.\n\n||secret||\n\nPara B."
new = self._seen(stored).replace("Para A.", "A2.").replace("Para B.", "B2.")
assert merge_gm_secrets(stored, new) == "A2.\n\n||secret||\n\nB2."

def test_secret_does_not_move_below_following_paragraph(self):
stored = "First paragraph here. ||twist|| Second paragraph here."
# Player rewrote the first paragraph but left the second intact.
new = self._seen(stored).replace(
"First paragraph here.", "A different first paragraph."
)
merged = merge_gm_secrets(stored, new)
assert merged.index("||twist||") < merged.index("Second paragraph")

def test_rewriting_both_sides_appends_secret_so_it_survives(self):
# When the anchor text on both sides of the secret is gone, the position
# can't be recovered — the secret is appended rather than lost.
stored = "Original A. ||the twist|| Original B."
new = "Completely different content with nothing in common."
merged = merge_gm_secrets(stored, new)
assert merged.startswith("Completely different content")
assert "||the twist||" in merged

def test_multiple_secrets_all_kept_in_order(self):
stored = "aaaa ||one|| bbbb ||two|| cccc"
assert merge_gm_secrets(stored, self._seen(stored)) == stored

def test_multiple_secrets_survive_edits_around_each(self):
stored = "A ||one|| B ||two|| C"
new = self._seen(stored).replace("A ", "A! ").replace(" C", " C?")
merged = merge_gm_secrets(stored, new)
assert "||one||" in merged and "||two||" in merged
assert merged.index("one") < merged.index("two")

def test_adjacent_secrets_kept_in_document_order(self):
stored = "x ||one||||two|| y"
assert merge_gm_secrets(stored, self._seen(stored)) == stored

def test_multiline_secret_restored_intact(self):
stored = "Intro.\n||line one\nline two||\nOutro."
assert merge_gm_secrets(stored, self._seen(stored)) == stored

def test_secret_at_start_of_body(self):
stored = "||opening|| then visible."
new = self._seen(stored).replace("visible", "visible text")
assert merge_gm_secrets(stored, new) == "||opening|| then visible text."

def test_secret_at_end_of_body(self):
stored = "Visible lead-in. ||closing||"
new = self._seen(stored).replace("Visible", "The visible")
assert merge_gm_secrets(stored, new) == "The visible lead-in. ||closing||"

def test_none_new_body_still_preserves_secret(self):
merged = merge_gm_secrets("x ||secret||", None)
assert "||secret||" in merged

def test_no_marker_or_secret_text_ever_shown_to_player(self):
# The player's copy leaks nothing: not the text, not a placeholder token.
stored = "Visible ||the duke is a doppelganger|| more"
seen = self._seen(stored)
assert "doppelganger" not in seen
assert "||" not in seen
assert "⟦" not in seen and "GM·" not in seen
Loading
Loading