77 */
88package org .dspace .subscriptions ;
99
10- import static java .nio .charset .StandardCharsets .UTF_8 ;
10+ import static java .util .stream .Collectors .groupingBy ;
11+ import static java .util .stream .Collectors .joining ;
12+ import static java .util .stream .Collectors .partitioningBy ;
13+ import static java .util .stream .Collectors .toList ;
1114import static org .apache .commons .lang3 .StringUtils .EMPTY ;
1215
13- import java .io .ByteArrayOutputStream ;
16+ import java .time .ZoneOffset ;
17+ import java .time .ZonedDateTime ;
18+ import java .time .temporal .ChronoUnit ;
19+ import java .util .ArrayList ;
20+ import java .util .Collections ;
1421import java .util .HashMap ;
22+ import java .util .HashSet ;
1523import java .util .List ;
1624import java .util .Locale ;
1725import java .util .Map ;
1826import java .util .Objects ;
1927import java .util .Optional ;
28+ import java .util .Set ;
29+ import java .util .concurrent .ConcurrentHashMap ;
30+ import java .util .function .Function ;
31+ import java .util .function .Predicate ;
2032
33+ import org .apache .commons .lang3 .StringUtils ;
34+ import org .apache .commons .lang3 .tuple .ImmutablePair ;
2135import org .apache .logging .log4j .LogManager ;
2236import org .apache .logging .log4j .Logger ;
37+ import org .dspace .content .Collection ;
38+ import org .dspace .content .Community ;
39+ import org .dspace .content .DCDate ;
2340import org .dspace .content .Item ;
24- import org .dspace .content .crosswalk .StreamDisseminationCrosswalk ;
41+ import org .dspace .content .MetadataValue ;
42+ import org .dspace .content .crosswalk .SubscriptionDsoMetadataForEmailCompose ;
2543import org .dspace .content .service .ItemService ;
2644import org .dspace .core .Context ;
2745import org .dspace .core .Email ;
3654 * which will handle the logic of sending the emails
3755 * in case of 'content' subscriptionType
3856 */
57+ @ org .springframework .stereotype .Component
3958@ SuppressWarnings ("rawtypes" )
4059public class ContentGenerator implements SubscriptionGenerator <IndexableObject > {
4160
4261 private final Logger log = LogManager .getLogger (ContentGenerator .class );
4362
4463 @ SuppressWarnings ("unchecked" )
45- private Map <String , StreamDisseminationCrosswalk > entityType2Disseminator = new HashMap ();
64+ private Map <String , SubscriptionDsoMetadataForEmailCompose > entityType2Disseminator = new HashMap ();
4665
4766 @ Autowired
4867 private ItemService itemService ;
4968
69+ private static final int MAX_METADATA_VALUES = 3 ;
70+ private static final String NEW_ITEMS_LABEL_KEY = "org.dspace.subscriptions.ContentGenerator.new-items-label" ;
71+ private static final String MODIFIED_ITEMS_LABEL_KEY =
72+ "org.dspace.subscriptions.ContentGenerator.modified-items-label" ;
73+ private static final String INTRO_NEW_AND_MODIFIED_KEY =
74+ "org.dspace.subscriptions.ContentGenerator.intro.new-and-modified" ;
75+ private static final String INTRO_NEW_KEY = "org.dspace.subscriptions.ContentGenerator.intro.new" ;
76+ private static final String INTRO_MODIFIED_KEY = "org.dspace.subscriptions.ContentGenerator.intro.modified" ;
77+ private static final String COMMUNITY_NOTE_PREFIX_KEY =
78+ "org.dspace.subscriptions.ContentGenerator.community-note-prefix" ;
79+ private static final String COMMUNITY_NOTE_SUFFIX_KEY =
80+ "org.dspace.subscriptions.ContentGenerator.community-note-suffix" ;
81+ private static final String LINE_SEPARATOR = System .lineSeparator ();
82+
83+ private static <T > Predicate <T > distinctByKey (Function <? super T , ?> keyExtractor ) {
84+ Set <Object > seen = ConcurrentHashMap .newKeySet ();
85+ return t -> seen .add (keyExtractor .apply (t ));
86+ }
87+
5088 @ Override
5189 public void notifyForSubscriptions (Context context , EPerson ePerson ,
52- List <IndexableObject > indexableComm ,
53- List <IndexableObject > indexableColl ) {
90+ Map < Community , List <IndexableObject >> commMap ,
91+ Map < Collection , List <IndexableObject >> collMap ) {
5492 try {
5593 if (Objects .nonNull (ePerson )) {
5694 Locale supportedLocale = I18nUtil .getEPersonLocale (ePerson );
5795 Email email = Email .getEmail (I18nUtil .getEmailFilename (supportedLocale , "subscriptions_content" ));
5896 email .addRecipient (ePerson .getEmail ());
5997
60- String bodyCommunities = generateBodyMail (context , indexableComm );
61- String bodyCollections = generateBodyMail (context , indexableColl );
62- if (bodyCommunities .equals (EMPTY ) && bodyCollections .equals (EMPTY )) {
63- log .debug ("subscription(s) of eperson {} do(es) not match any new items: nothing to send" +
64- " - exit silently" , ePerson ::getID );
98+ // Create a map which holds the collections and their corresponding community names if we have updates
99+ // That originally came from a community subscription, so we can refer to this in the email
100+ Map <Collection , String > collectionToCommunityNameMap = buildCommunityCollectionMap (commMap );
101+ List <IndexableObject > allItems = new ArrayList <>();
102+ if (commMap != null ) {
103+ commMap .values ().forEach (allItems ::addAll );
104+ }
105+ if (collMap != null ) {
106+ collMap .values ().forEach (allItems ::addAll );
107+ }
108+
109+ Map <Boolean , List <IndexableObject >> partitionedItems = allItems .stream ()
110+ .filter (distinctByKey (IndexableObject ::getID ))
111+ .collect (partitioningBy (obj -> isNewItem ((Item ) obj .getIndexedObject ())));
112+
113+ Map <Collection , List <IndexableObject >> newItemsByCollection = partitionedItems .get (true ).stream ()
114+ .collect (groupingBy (obj -> ((Item ) obj .getIndexedObject ()).getOwningCollection ()));
115+
116+ Map <Collection , List <IndexableObject >> modifiedItemsByCollection = partitionedItems .get (false ).stream ()
117+ .collect (groupingBy (obj -> ((Item ) obj .getIndexedObject ()).getOwningCollection ()));
118+
119+ String intro = buildIntro (newItemsByCollection , modifiedItemsByCollection , supportedLocale );
120+ String combinedSection = buildCombinedSection (newItemsByCollection , modifiedItemsByCollection ,
121+ collectionToCommunityNameMap , supportedLocale );
122+
123+ if (combinedSection .equals (EMPTY )) {
124+ log .debug ("subscription(s) of eperson {} do(es) not match any new or modified items: " +
125+ "nothing to send - exit silently" , ePerson ::getID );
65126 return ;
66127 }
67- email .addArgument (bodyCommunities );
68- email .addArgument (bodyCollections );
128+ email .addArgument (intro );
129+ email .addArgument (combinedSection );
69130 email .send ();
70131 }
71132 } catch (Exception e ) {
@@ -74,30 +135,166 @@ public void notifyForSubscriptions(Context context, EPerson ePerson,
74135 }
75136 }
76137
77- private String generateBodyMail (Context context , List <IndexableObject > indexableObjects ) {
78- if (indexableObjects == null || indexableObjects .isEmpty ()) {
138+ private static Map <Collection , String > buildCommunityCollectionMap (Map <Community , List <IndexableObject >> commMap ) {
139+ if (commMap == null ) {
140+ return Collections .emptyMap ();
141+ }
142+ Map <Collection , String > resultMap = new HashMap <>();
143+ commMap .forEach ((community , items ) -> items .forEach (obj -> {
144+ Item item = (Item ) obj .getIndexedObject ();
145+ Collection collection = item .getOwningCollection ();
146+ resultMap .putIfAbsent (collection , community .getName ());
147+ }));
148+ return resultMap ;
149+ }
150+
151+ private boolean isNewItem (Item item ) {
152+ Optional <ZonedDateTime > createdDate =
153+ itemService .getMetadata (item , "dc" , "date" , "accessioned" , Item .ANY )
154+ .stream ()
155+ .map (MetadataValue ::getValue )
156+ .findFirst ()
157+ .map (val -> new DCDate (val ).toDate ().toInstant ())
158+ .map (instant -> ZonedDateTime .ofInstant (instant , ZoneOffset .UTC ));
159+
160+ ZonedDateTime lastModified = ZonedDateTime .ofInstant (item .getLastModified (), ZoneOffset .UTC );
161+
162+ return createdDate .map (createdZoned -> createdZoned .truncatedTo (ChronoUnit .SECONDS )
163+ .equals (lastModified .truncatedTo (ChronoUnit .SECONDS )))
164+ .orElse (false );
165+ }
166+
167+ private String buildIntro (Map <Collection , List <IndexableObject >> newItems ,
168+ Map <Collection , List <IndexableObject >> modifiedItems ,
169+ Locale locale ) {
170+ if (!newItems .isEmpty () && !modifiedItems .isEmpty ()) {
171+ return I18nUtil .getMessage (INTRO_NEW_AND_MODIFIED_KEY , locale );
172+ } else if (!newItems .isEmpty ()) {
173+ return I18nUtil .getMessage (INTRO_NEW_KEY , locale );
174+ } else if (!modifiedItems .isEmpty ()) {
175+ return I18nUtil .getMessage (INTRO_MODIFIED_KEY , locale );
176+ } else {
177+ return "" ;
178+ }
179+ }
180+
181+ private String buildCombinedSection (Map <Collection , List <IndexableObject >> newItemsByCollection ,
182+ Map <Collection , List <IndexableObject >> modifiedItemsByCollection ,
183+ Map <Collection , String > collectionToCommunity ,
184+ Locale locale ) {
185+ if (newItemsByCollection .isEmpty () && modifiedItemsByCollection .isEmpty ()) {
79186 return EMPTY ;
80187 }
81- try {
82- ByteArrayOutputStream out = new ByteArrayOutputStream ();
83- out .write ("\n " .getBytes (UTF_8 ));
84- for (IndexableObject indexableObject : indexableObjects ) {
85- out .write ("\n " .getBytes (UTF_8 ));
86- Item item = (Item ) indexableObject .getIndexedObject ();
87- String entityType = itemService .getEntityTypeLabel (item );
88- Optional .ofNullable (entityType2Disseminator .get (entityType ))
89- .orElseGet (() -> entityType2Disseminator .get ("Item" ))
90- .disseminate (context , item , out );
188+
189+ StringBuilder sb = new StringBuilder ();
190+ Set <Collection > allCollections = new HashSet <>();
191+ allCollections .addAll (newItemsByCollection .keySet ());
192+ allCollections .addAll (modifiedItemsByCollection .keySet ());
193+
194+ for (Collection collection : allCollections ) {
195+ String header = buildCollectionHeader (collection , collectionToCommunity , locale );
196+ sb .append (header );
197+
198+ List <IndexableObject > newItems = newItemsByCollection .get (collection );
199+ List <IndexableObject > modifiedItems = modifiedItemsByCollection .get (collection );
200+ if (newItems != null && !newItems .isEmpty ()) {
201+ sb .append (buildSectionHeader (I18nUtil .getMessage (NEW_ITEMS_LABEL_KEY , locale ), newItems .size ()));
202+ sb .append (buildItemsBlock (newItems ));
203+ }
204+ if (modifiedItems != null && !modifiedItems .isEmpty ()) {
205+ sb .append (buildSectionHeader (I18nUtil .getMessage (MODIFIED_ITEMS_LABEL_KEY , locale ),
206+ modifiedItems .size ()));
207+ sb .append (buildItemsBlock (modifiedItems ));
208+ }
209+ sb .append (LINE_SEPARATOR );
210+ }
211+ return sb .toString ();
212+ }
213+
214+ private String buildCollectionHeader (Collection collection , Map <Collection , String > collectionToCommunity ,
215+ Locale locale ) {
216+ String name = collection .getName ();
217+ String communityNote = "" ;
218+ if (collectionToCommunity != null && collectionToCommunity .containsKey (collection )) {
219+ communityNote = I18nUtil .getMessage (COMMUNITY_NOTE_PREFIX_KEY , locale )
220+ + collectionToCommunity .get (collection )
221+ + I18nUtil .getMessage (COMMUNITY_NOTE_SUFFIX_KEY , locale );
222+ }
223+ String fullHeader = name + communityNote + ":" + LINE_SEPARATOR ;
224+ String underline = "-" .repeat (Math .max (0 , name .length () + communityNote .length ())) + LINE_SEPARATOR ;
225+ return fullHeader + underline ;
226+ }
227+
228+ private String buildSectionHeader (String label , int count ) {
229+ StringBuilder sb = new StringBuilder ();
230+ sb .append (" " ).append (label ).append (" (" ).append (count ).append ("):" )
231+ .append (LINE_SEPARATOR ).append (LINE_SEPARATOR );
232+ return sb .toString ();
233+ }
234+
235+ private String buildItemsBlock (List <IndexableObject > items ) {
236+ StringBuilder sb = new StringBuilder ();
237+ for (IndexableObject obj : items ) {
238+ Item item = (Item ) obj .getIndexedObject ();
239+ sb .append (formatItem (item )).append (LINE_SEPARATOR ).append (LINE_SEPARATOR );
240+ }
241+ return sb .toString ();
242+ }
243+
244+ private String formatItem (Item item ) {
245+ String entityType = itemService .getEntityTypeLabel (item );
246+ SubscriptionDsoMetadataForEmailCompose crosswalk = entityType2Disseminator .get (entityType );
247+ if (crosswalk == null ) {
248+ crosswalk = entityType2Disseminator .get ("Item" ); // fallback
249+ }
250+ if (crosswalk == null ) {
251+ throw new IllegalStateException ("No crosswalk found for entity type: " + entityType );
252+ }
253+ StringBuilder sb = new StringBuilder ();
254+ for (ImmutablePair <String , String > mdPair : crosswalk .getMetadata ()) {
255+ String field = mdPair .getLeft ();
256+ String label = mdPair .getRight ();
257+ List <String > values = getAllMetadata (item , field );
258+ if (!values .isEmpty ()) {
259+ addIfNotBlank (sb , label + ": " , values );
260+ }
261+ }
262+ return sb .toString ();
263+ }
264+
265+ private void addIfNotBlank (StringBuilder sb , String label , List <String > values ) {
266+ if (values != null && !values .isEmpty ()) {
267+ List <String > nonBlankValues = values .stream ()
268+ .filter (StringUtils ::isNotBlank )
269+ .collect (toList ());
270+
271+ if (!nonBlankValues .isEmpty ()) {
272+ String joined = nonBlankValues .stream ()
273+ .limit (MAX_METADATA_VALUES )
274+ .collect (joining (", " ));
275+ sb .append ("\t " ).append (label ).append (joined );
276+ if (nonBlankValues .size () > MAX_METADATA_VALUES ) {
277+ sb .append (", ..." );
278+ }
279+ sb .append (LINE_SEPARATOR );
91280 }
92- out .close ();
93- return out .toString ();
94- } catch (Exception e ) {
95- log .error (e .getMessage (), e );
96281 }
97- return EMPTY ;
98282 }
99283
100- public void setEntityType2Disseminator (Map <String , StreamDisseminationCrosswalk > entityType2Disseminator ) {
284+ private List <String > getAllMetadata (Item item , String field ) {
285+ String [] fieldParts = field .split ("\\ ." );
286+ String schema = fieldParts [0 ];
287+ String element = fieldParts [1 ];
288+ String qualifier = fieldParts .length > 2 ? fieldParts [2 ] : null ;
289+ return itemService .getMetadata (item , schema , element , qualifier , Item .ANY )
290+ .stream ()
291+ .map (MetadataValue ::getValue )
292+ .filter (Objects ::nonNull )
293+ .collect (toList ());
294+ }
295+
296+ public void setEntityType2Disseminator (Map <String , SubscriptionDsoMetadataForEmailCompose >
297+ entityType2Disseminator ) {
101298 this .entityType2Disseminator = entityType2Disseminator ;
102299 }
103300
0 commit comments