Class InvitationText

java.lang.Object
org.exoplatform.agenda.util.InvitationText

public final class InvitationText extends Object
Recognises, in a description, the invitation text EventIcsBuilder itself composes — so that a description which already carries one is not given a second.

Why a description can carry one at all. The builder writes its text — attribution, event link, answer links, then the organiser's own words under a label — into every calendar copy and every mailed document. A copy read back into eXo by the CalDAV connector keeps that whole text as the imported event's description, and when two eXo users share one CalDAV account the copy one of them pushed is exactly what the other imports. Their next push handed that description back to the builder, which prepended its text again; the first user imported the result and pushed again; and the object grew by one "Invitation sent by X. Event link: …eventId=N" block per edit, alternating between the two users' event ids (EXO-90227, observed on a rig against Stalwart: two blocks, then three).

Replaced, not skipped. When the text handed in already begins with an invitation block, the builder takes that block off and writes its own — rather than leaving the old one and writing nothing. The old block is somebody else's: it names the user who pushed first, in their language, links to their event, and in a calendar copy offers their tokenised answer links, which answer as them. Re-emitting it would hand that person's tokens to every reader of every copy of this event, and attribute the meeting to whoever happened to push first. Writing the current block instead gives each reader what every other copy gives them: the attribution and the links minted for them.

Any eXo's block, by shape and not by words. The labels are read in the recipient's locale, and the builder reading a description back cannot know which locale wrote it — nor which deployment, nor which event id. So no word of any translation is matched. What is matched is the layout the builder produces, and all four of its joints must be there:

  1. a line that is a label — one having the shape of a label (looksLikeALabel(java.lang.String): at most LABEL_MAX_LENGTH characters and ending in a colon, or a bundle key of ours) — then whitespace, then one eXo event link and nothing else;
  2. that line as the second or third non-blank line of the text, after the attribution sentence and at most one conference line;
  3. optionally an answer block under it: a prompt, then one to three lines each carrying a tokenised answer link — the one thing in this layout nobody can type, so nothing else is asked of those lines;
  4. then either nothing at all, or a line that has the shape of a label — the one under which the builder puts the organiser's text.
Everything up to and including that label line is the block; what follows is the organiser's text. Miss any joint and the text is returned exactly as given.

And one invariant over the whole of it, checked on the way out. A reading that would hand back a text still carrying an answer link is refused outright, and the text is returned as given: see stripFrom(java.lang.String). Joints are a hypothesis about a layout, and a hypothesis can be wrong in a way nobody has thought of yet; that one token must not be re-emitted as the organiser's own words is not a hypothesis, so it is asserted on the result rather than inferred from the joints.

What is accepted as a false negative. Every one of these leaves the text untouched, which is to say it would still stack — the price of reading shape rather than words, and paid in the direction that loses nothing:

  • A block whose event link the pusher withheld — a guest recipient, or a deployment whose portal could not be asked for its address — has no link line. Neither case reaches this path today: the connector writes copies for eXo users only, and a copy with no link is one this deployment did not compose.
  • A link line as the fourth or later non-blank line, on purpose (below).
  • A label the bundle answers blank — guarded, and listed here so it stays guarded. description() would write "" + " " + url and the line would carry no label at all, so joint 1 would not be met. EventIcsBuilder's own label lookup answers the key when the bundle answers blank instead, and the line keeps a label whatever Crowdin ships. Not idle plumbing — agenda.eventLink and agenda.rsvpPrompt are in Agenda_en.properties alone, and every other locale reads them through the English parent bundle (BaseResourceBundleService, whose default locale is en), while Agenda_sq.properties already ships an untranslated Crowdin placeholder as its agenda.eventDetail.
  • A detail label that does not end in a colon. 38 of the 39 bundles that translate agenda.eventDetail end it with one; the exception is that Albanian placeholder. A locale whose label loses its colon loses recognition with it, for that locale's readers only.
  • An event-link label that does not end in a colon either, since joint 1 asks the same of it. Today that costs nothing at all: agenda.eventLink is in Agenda_en.properties alone (Event link:), so every locale reads a label ending in a colon through the English parent bundle. The day Crowdin translates that key the cost becomes the same as the detail label's, and about as likely: of the 39 translations of agenda.visioLink — the nearest comparable label — 4 drop the colon (Arabic, Arabic-aro, German, and the Albanian placeholder).
  • A server that inserts a blank line the builder did not write, between the answer prompt and the answer links. pastAnswerBlock(java.util.List<java.lang.String>, int) reads the answers on the lines immediately under the prompt — the builder writes them with one newline apiece and none observed server folds a line apart — so a blank line there strands the answer links below the point the block would end at, and the invariant then refuses the reading altogether rather than returning them. Fail-closed, and the pin noPartialStripEverKeepsSomebodyElsesAnswerToken is what keeps it that way.
  • A server that folds a line the builder wrote onto another — the attribution onto the link line, which eventLinkLine(java.util.List<java.lang.String>) stops looking for at the first non-blank line, or the detail label onto the first line of the text, which then ends in that text rather than in a colon. BlueMind, the one server observed folding anything, folds blank lines only and keeps both apart.
  • The occurrence link shape. NotificationUtils also composes a link carrying parentId= and occurrenceId= for one occurrence of a recurrent event, which the link pattern does not match — it requires eventId= and then the end of the line. Unreachable today and a deliberate guard, not a description of the code: every path into the builder passes the URL from EventIcsBuilder.eventUrl(long), which writes ?eventId=. It is written down because the coupling that would make it reachable lives in another class — one caller away — and this ledger is where that caller's author would look.

What keeps this from destroying text a person typed. A link to an eXo event pasted into a sentence, a link on the first line, a link with words after it or a full stop after it, a bare link on a line of its own, a link in the fourth paragraph, a link under a sentence rather than a label, a link after a word that is not a label (see …, Here …), a list of several event links, a link followed by a line of prose: none has the shape, and the very instance given is returned. Two joints do that work and they do it at both ends — the line carrying the link must open with a label, and the line that ends the block must be one too — because a person's line around a link is prose, and prose does not end in a colon.

What remains, stated so nobody has to rediscover it. A person writing, by hand and in this order, a one-line paragraph, then a line that is a label ending in a colon and an eXo event link alone after it, then a line that itself ends in a colon or is a bundle key of ours, and then their text — Weekly sync. / Agenda: …eventId=87 / Notes: / bring the deck. — loses everything down to that line, here and in every channel EventIcsBuilder renders. No reading of the text can close that: it is, character for character, the shape the builder itself writes, and what tells them apart is the words of a label in one of 39 bundles that this module cannot read. Closing it needs a signal this class is not given — whether the description came in through an import at all — which is a change to what the builder's callers pass, not to what this reads (EXO-90228, recorded for the Architect). It is the price of reading no words, and it is bounded to the head of the text.

What a false positive costs, so the price is on the page. The block is recognised in the text, not in the markup, so when it is recognised the remainder of a rich description is re-rendered from plain text — see EventIcsBuilder's HTML flavour: bold, links and lists a person wrote in the editor arrive as words. On a true positive that costs nothing (the text came in as plain text). On a false positive it is a second loss on top of the deleted lines. Widening any joint above is therefore not a trade of recall against a little noise; read both this paragraph and that method's before touching one.

Fails closed, and the one outcome that would not be is refused explicitly. Anything not recognised is returned untouched, so the worst outcome for a text this cannot read is the behaviour before this class existed. That is not true of a partial reading — one that ends the block above somebody else's answer links and hands those back as the organiser's text — which is strictly worse than both outcomes: the tokens are re-emitted and the attribution that said whose they were is deleted, so no reader can tell. stripFrom(java.lang.String) therefore asserts on its result that this cannot happen, and returns the text as given when it would. The coupling is to the builder's own layout — which lives in the same package and is pinned twice, because one pin cannot see the whole of it: InvitationTextTest.renderingWhatTheBuilderRenderedIsTheSameCopy renders outside a container, where every label degrades to its bundle key, so it fails on a drift in the layout — a line moved, a joint removed — and cannot see a drift that needs a real label to show, and renderingWithLabelsFromABundleIsTheSameCopy renders the same layout with the labels read from a bundle, so the localised shape is covered too. A drift visible in neither — a translation reshaped in Crowdin — is what the false-negative ledger above is for.

That coupling crosses a repository, and a change to the layout is therefore a two-repository change. The connector calls this on the way IN — caldav-integration's IcsEventMapper.toEvent — so that the description eXo stores carries the organiser's text alone; the builder calls it on the way OUT, so that every channel it renders carries one block. Neither call site can replace the other: the builder never touches what the event drawer, the mail body and search read, and the import never re-reads an object nobody edits again. What the pin above cannot see is the import half — a layout change would degrade it silently, fail-closed, with no test in this repository noticing. Change the layout and the recogniser together, and say so in the connector's PR.

  • Method Details

    • stripFrom

      public static String stripFrom(String text)
      The text without the invitation block composed in front of it, or the text exactly as given when it carries none.

      Applied until nothing more is recognised: a description that already stacked several blocks before this existed — the rig held three — renders with the organiser's text alone, not with one block fewer.

      The one thing this promises, whatever the joints did. Either the text is returned as given, or what is returned carries no answer link — never a reading that took a block off and kept somebody else's tokens under the organiser's name. The joints are meant to make that unreachable; this makes it so. Two ways in were known when it was written (an answer label longer than a label, a server rewriting the bracketed repetition), both closed at ANSWER_LINK as well, and one was found while writing this guard (a blank line inserted between the prompt and the answers) — which is the argument for asserting the outcome instead of trusting the enumeration.

      Refusing is the milder failure and not a free one: the text keeps its old block, so this event's copies stack one more, exactly as they did before this class existed. That is the price of not laundering one person's authentication token into another person's mail (AgendaEventRest honours it with no session at all), and it is not close.

      Parameters:
      text - the description as plain text, null and blank tolerated
      Returns:
      what remains once every leading block is taken off; an empty string when the block was all there was; the input itself when no block is recognised, or when the reading would have kept an answer link