Skip to content
Open
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
73 changes: 64 additions & 9 deletions App.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,25 +112,80 @@ def outputAuthors():
)
self.runStep(f"Writing {name} output...", f"Wrote {name} output", outputAuthors)

@staticmethod
def _authorMatchKey(name):
"""Normalized display name used to match a guest author to a WP user.

Exact string equality is not enough: a WP user's display name is often
title-cased off the login ("Erik Heyman-meltzer") while the Co-Authors
Plus record for the same person is typed by hand ("Erik Heyman-Meltzer").
One capital apart is still one person. This is the same normalization
the within-pool dedupe already matches on.
"""
if not name:
return None
return Utility.cleanDocument(name, "similarity")

@staticmethod
def _absorbGuestAuthor(existing, gAuth):
"""Fold a matched guest author's fields into the WP user record.

Two deliberately narrow rules:
- an empty field is filled from the guest record, which is usually
where a real first/last name lives (the email comes from the WP
user side almost every time);
- a name field that differs from the guest's ONLY by case or
punctuation takes the guest's spelling, because that one was typed
by a person rather than derived from a login.

A field that genuinely differs is left alone. A guest record must never
be able to rename somebody.
"""
for field in ("display_name", "first_name", "last_name", "email"):
incoming = gAuth.data.get(field)
if not incoming:
continue
current = existing.data.get(field)
if not current:
existing.data[field] = incoming
elif field != "email" and current != incoming and (
Pipeline._authorMatchKey(current) == Pipeline._authorMatchKey(incoming)
):
existing.data[field] = incoming

def combineAndReindexAuthors(self, authors, guestAuthors):
"""Fold the guest-author pool into the WP-user pool.

The two pools are sanitized independently, so this is the ONLY place a
person represented on both sides gets collapsed into one row. Matching
too strictly here emits a second `authors` row for them, and
`Utility.canonicalizeAuthorLogins` then disambiguates the colliding
logins by appending the row id -- an author slug ending in its own id
is the fingerprint of a miss.
"""
combined = authors
authNames = {auth.data["display_name"] for auth in authors}
byName = {}
for auth in authors:
byName.setdefault(self._authorMatchKey(auth.data["display_name"]), auth)
usedIds = {
auth.data["id"]
for auth in authors
if auth.data.get("id") is not None
}
nextId = (max(usedIds) + 1) if usedIds else 0
for gAuth in guestAuthors:
gAuthName = gAuth.data["display_name"]
if gAuthName not in authNames:
while nextId in usedIds:
nextId += 1
gAuth.data["id"] = nextId
usedIds.add(nextId)
gAuthKey = self._authorMatchKey(gAuth.data["display_name"])
existing = byName.get(gAuthKey)
if existing is not None:
self._absorbGuestAuthor(existing, gAuth)
continue
while nextId in usedIds:
nextId += 1
authNames.add(gAuthName)
combined.append(gAuth)
gAuth.data["id"] = nextId
usedIds.add(nextId)
nextId += 1
byName[gAuthKey] = gAuth
combined.append(gAuth)
return combined

def sanitizeArticleAuthors(self, translators, allAuthors, best_guess=False):
Expand Down
113 changes: 113 additions & 0 deletions tests/test_author_pool_merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Tests for folding the guest-author pool into the WP-user pool.

Run from the repo root:

.venv/bin/python -m unittest tests.test_author_pool_merge
"""
import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from App import Pipeline
from Translator.Author import Author
from Utils.Utility import Utility


def pipeline():
noop = lambda *a, **k: None
return Pipeline(noop, noop, noop, noop, noop)


class CombineAuthorPools(unittest.TestCase):
def test_casing_drift_does_not_emit_a_second_row(self):
# The regression. The WP user's display name is title-cased off the
# login; the Co-Authors Plus record is hand-typed. Same person.
wpUser = Author(571, "Erik Heyman-meltzer", "Erik", "Heyman-meltzer",
"erik.heyman-meltzer@thetriangle.org", "erik-heyman-meltzer")
guest = Author(394, "Erik Heyman-Meltzer", "Erik", "Heyman-Meltzer",
None, "erik-heyman-meltzer")

combined = pipeline().combineAndReindexAuthors([wpUser], [guest])

self.assertEqual(len(combined), 1)
self.assertEqual(combined[0].data["id"], 571)

def test_no_duplicate_login_is_left_for_dedupe_slug_to_number(self):
# A missed match shows up downstream as a slug carrying the row id.
wpUser = Author(571, "Erik Heyman-meltzer", "Erik", "Heyman-meltzer",
"erik.heyman-meltzer@thetriangle.org", "erik-heyman-meltzer")
guest = Author(394, "Erik Heyman-Meltzer", "Erik", "Heyman-Meltzer",
None, "erik-heyman-meltzer")

combined = pipeline().combineAndReindexAuthors([wpUser], [guest])
Utility.canonicalizeAuthorLogins(combined)

self.assertEqual([a.data["login"] for a in combined], ["erik-heyman-meltzer"])

def test_hand_typed_spelling_wins_over_one_derived_from_the_login(self):
wpUser = Author(571, "Erik Heyman-meltzer", "Erik", "Heyman-meltzer",
"erik.heyman-meltzer@thetriangle.org", "erik-heyman-meltzer")
guest = Author(394, "Erik Heyman-Meltzer", "Erik", "Heyman-Meltzer",
None, "erik-heyman-meltzer")

combined = pipeline().combineAndReindexAuthors([wpUser], [guest])

self.assertEqual(combined[0].data["display_name"], "Erik Heyman-Meltzer")
self.assertEqual(combined[0].data["last_name"], "Heyman-Meltzer")

def test_the_email_survives_the_merge(self):
# The WP user side is where the address lives, and cms_users links to
# an author row BY EMAIL, so losing it unlinks the person's account.
wpUser = Author(571, "Erik Heyman-meltzer", "Erik", "Heyman-meltzer",
"erik.heyman-meltzer@thetriangle.org", "erik-heyman-meltzer")
guest = Author(394, "Erik Heyman-Meltzer", "Erik", "Heyman-Meltzer",
None, "erik-heyman-meltzer")

combined = pipeline().combineAndReindexAuthors([wpUser], [guest])

self.assertEqual(combined[0].data["email"], "erik.heyman-meltzer@thetriangle.org")

def test_blank_fields_are_filled_from_the_guest_record(self):
wpUser = Author(12, "Jane Doe", None, None, "jane.doe@thetriangle.org", "jane.doe")
guest = Author(3, "Jane Doe", "Jane", "Doe", None, "jane-doe")

combined = pipeline().combineAndReindexAuthors([wpUser], [guest])

self.assertEqual(combined[0].data["first_name"], "Jane")
self.assertEqual(combined[0].data["last_name"], "Doe")

def test_a_guest_record_cannot_rename_somebody(self):
# Only case/punctuation drift defers to the guest record. A genuinely
# different value is a different person's data.
wpUser = Author(12, "Jane Doe", "Jane", "Doe", "jane.doe@thetriangle.org", "jane.doe")
guest = Author(3, "Jane Doe", "Janet", "Doe", None, "jane-doe")

combined = pipeline().combineAndReindexAuthors([wpUser], [guest])

self.assertEqual(len(combined), 1)
self.assertEqual(combined[0].data["first_name"], "Jane")

def test_an_unmatched_guest_author_still_gets_a_fresh_id(self):
wpUser = Author(12, "Jane Doe", "Jane", "Doe", "jane.doe@thetriangle.org", "jane.doe")
guest = Author(3, "Someone Else", "Someone", "Else", None, "someone-else")

combined = pipeline().combineAndReindexAuthors([wpUser], [guest])

self.assertEqual(len(combined), 2)
self.assertEqual(combined[1].data["id"], 13)

def test_fresh_ids_never_collide_with_a_wp_user_id(self):
authors = [Author(0, "A A"), Author(5, "B B")]
guests = [Author(1, "C C"), Author(2, "D D")]

combined = pipeline().combineAndReindexAuthors(authors, guests)

ids = [a.data["id"] for a in combined]
self.assertEqual(len(ids), len(set(ids)))
self.assertEqual(ids[2:], [6, 7])


if __name__ == "__main__":
unittest.main()