1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 package org.mycore.frontend.cli;
20
21 import java.io.File;
22 import java.io.FileOutputStream;
23 import java.io.IOException;
24 import java.net.MalformedURLException;
25 import java.net.URISyntaxException;
26 import java.net.URL;
27 import java.nio.file.Paths;
28 import java.util.ArrayList;
29 import java.util.Arrays;
30 import java.util.Collections;
31 import java.util.List;
32 import java.util.Locale;
33 import java.util.stream.Collectors;
34
35 import javax.xml.transform.Transformer;
36 import javax.xml.transform.TransformerConfigurationException;
37 import javax.xml.transform.TransformerException;
38 import javax.xml.transform.TransformerFactory;
39 import javax.xml.transform.TransformerFactoryConfigurationError;
40 import javax.xml.transform.stream.StreamResult;
41 import javax.xml.transform.stream.StreamSource;
42
43 import org.apache.logging.log4j.LogManager;
44 import org.apache.logging.log4j.Logger;
45 import org.jdom2.Document;
46 import org.jdom2.output.Format;
47 import org.jdom2.output.XMLOutputter;
48 import org.jdom2.transform.JDOMSource;
49 import org.mycore.backend.jpa.MCREntityManagerProvider;
50 import org.mycore.common.MCRConstants;
51 import org.mycore.common.MCRException;
52 import org.mycore.common.content.MCRSourceContent;
53 import org.mycore.common.content.MCRURLContent;
54 import org.mycore.common.xml.MCRURIResolver;
55 import org.mycore.common.xml.MCRXMLParserFactory;
56 import org.mycore.datamodel.classifications2.MCRCategory;
57 import org.mycore.datamodel.classifications2.MCRCategoryDAO;
58 import org.mycore.datamodel.classifications2.MCRCategoryDAOFactory;
59 import org.mycore.datamodel.classifications2.MCRCategoryID;
60 import org.mycore.datamodel.classifications2.MCRLabel;
61 import org.mycore.datamodel.classifications2.MCRUnmappedCategoryRemover;
62 import org.mycore.datamodel.classifications2.impl.MCRCategoryDAOImpl;
63 import org.mycore.datamodel.classifications2.impl.MCRCategoryImpl;
64 import org.mycore.datamodel.classifications2.utils.MCRCategoryTransformer;
65 import org.mycore.datamodel.classifications2.utils.MCRXMLTransformer;
66 import org.mycore.datamodel.common.MCRActiveLinkException;
67 import org.mycore.frontend.cli.annotation.MCRCommand;
68 import org.mycore.frontend.cli.annotation.MCRCommandGroup;
69 import org.xml.sax.SAXParseException;
70
71 import jakarta.persistence.EntityManager;
72
73
74
75
76
77
78 @MCRCommandGroup(name = "Classification Commands")
79 public class MCRClassification2Commands extends MCRAbstractCommands {
80 private static Logger LOGGER = LogManager.getLogger();
81
82 private static final MCRCategoryDAO DAO = MCRCategoryDAOFactory.getInstance();
83
84
85 public static final String DEFAULT_TRANSFORMER = "save-classification.xsl";
86
87
88
89
90
91
92
93 @MCRCommand(syntax = "delete classification {0}",
94 help = "The command remove the classification with MCRObjectID {0} from the system.",
95 order = 30)
96 public static void delete(String classID) {
97 DAO.deleteCategory(MCRCategoryID.rootID(classID));
98 }
99
100
101
102
103
104
105 @MCRCommand(syntax = "count classification children of {0}",
106 help = "The command count the categoies of the classification with MCRObjectID {0} in the system.",
107 order = 80)
108 public static void countChildren(String classID) {
109 MCRCategory category = DAO.getCategory(MCRCategoryID.rootID(classID), 1);
110 System.out.printf(Locale.ROOT, "%s has %d children", category.getId(), category.getChildren().size());
111 }
112
113
114
115
116
117
118
119
120
121
122 @MCRCommand(syntax = "load classification from file {0}",
123 help = "The command adds a new classification from file {0} to the system.",
124 order = 10)
125 public static List<String> loadFromFile(String filename) throws URISyntaxException, MCRException, SAXParseException,
126 IOException {
127 String fileURL = Paths.get(filename).toAbsolutePath().normalize().toUri().toURL().toString();
128 return Collections.singletonList("load classification from url " + fileURL);
129 }
130
131 @MCRCommand(syntax = "load classification from url {0}",
132 help = "The command adds a new classification from URL {0} to the system.",
133 order = 15)
134 public static void loadFromURL(String fileURL) throws SAXParseException, MalformedURLException, URISyntaxException {
135 Document xml = MCRXMLParserFactory.getParser().parseXML(new MCRURLContent(new URL(fileURL)));
136 MCRCategory category = MCRXMLTransformer.getCategory(xml);
137 DAO.addCategory(null, category);
138 }
139
140 @MCRCommand(syntax = "load classification from uri {0}",
141 help = "The command adds a new classification from URI {0} to the system.",
142 order = 17)
143 public static void loadFromURI(String fileURI) throws SAXParseException, URISyntaxException, TransformerException {
144 Document xml = MCRXMLParserFactory.getParser().parseXML(MCRSourceContent.getInstance(fileURI));
145 MCRCategory category = MCRXMLTransformer.getCategory(xml);
146 DAO.addCategory(null, category);
147 }
148
149
150
151
152
153
154
155
156 @MCRCommand(syntax = "update classification from file {0}",
157 help = "The command updates a classification from file {0} to the system.",
158 order = 20)
159 public static List<String> updateFromFile(String filename)
160 throws URISyntaxException, MCRException, SAXParseException,
161 IOException {
162 String fileURL = Paths.get(filename).toAbsolutePath().normalize().toUri().toURL().toString();
163 return Collections.singletonList("update classification from url " + fileURL);
164 }
165
166 @MCRCommand(syntax = "update classification from url {0}",
167 help = "The command updates a classification from URL {0} to the system.",
168 order = 25)
169 public static void updateFromURL(String fileURL)
170 throws SAXParseException, MalformedURLException, URISyntaxException {
171 Document xml = MCRXMLParserFactory.getParser().parseXML(new MCRURLContent(new URL(fileURL)));
172 MCRCategory category = MCRXMLTransformer.getCategory(xml);
173 if (DAO.exist(category.getId())) {
174 DAO.replaceCategory(category);
175 } else {
176
177 DAO.addCategory(null, category);
178 }
179 }
180
181 @MCRCommand(syntax = "update classification from uri {0}",
182 help = "The command updates a classification from URI {0} to the system.",
183 order = 27)
184 public static void updateFromURI(String fileURI) throws SAXParseException, URISyntaxException,
185 TransformerException {
186 Document xml = MCRXMLParserFactory.getParser().parseXML(MCRSourceContent.getInstance(fileURI));
187 MCRCategory category = MCRXMLTransformer.getCategory(xml);
188 if (DAO.exist(category.getId())) {
189 DAO.replaceCategory(category);
190 } else {
191
192 DAO.addCategory(null, category);
193 }
194 }
195
196
197
198
199
200
201
202 @MCRCommand(syntax = "load all classifications from directory {0}",
203 help = "The command add all classifications in the directory {0} to the system.",
204 order = 40)
205 public static List<String> loadFromDirectory(String directory) throws MCRActiveLinkException {
206 return processFromDirectory(directory, false);
207 }
208
209
210
211
212
213
214
215 @MCRCommand(syntax = "update all classifications from directory {0}",
216 help = "The command update all classifications in the directory {0} to the system.",
217 order = 50)
218 public static List<String> updateFromDirectory(String directory) throws MCRActiveLinkException {
219 return processFromDirectory(directory, true);
220 }
221
222
223
224
225
226
227
228
229
230
231
232 private static List<String> processFromDirectory(String directory, boolean update) throws MCRActiveLinkException {
233 File dir = new File(directory);
234
235 if (!dir.isDirectory()) {
236 LOGGER.warn("{} ignored, is not a directory.", directory);
237 return null;
238 }
239
240 String[] list = dir.list();
241
242 if (list.length == 0) {
243 LOGGER.warn("No files found in directory {}", directory);
244 return null;
245 }
246
247 return Arrays.stream(list)
248 .filter(file -> file.endsWith(".xml"))
249 .map(file -> (update ? "update" : "load") + " classification from file " + new File(
250 dir, file).getAbsolutePath())
251 .collect(Collectors.toList());
252 }
253
254
255
256
257
258
259
260
261
262
263
264
265
266 @MCRCommand(syntax = "export classification {0} to directory {1} with {2}",
267 help = "The command exports the classification with MCRObjectID {0} as xml file to directory named {1} "
268 + "using the stylesheet {2}-object.xsl. For {2} save is the default.",
269 order = 60)
270 public static boolean export(String id, String dirname, String style) throws Exception {
271 String dname = "";
272 if (dirname.length() != 0) {
273 try {
274 File dir = new File(dirname);
275 if (!dir.isDirectory()) {
276 dir.mkdir();
277 }
278 if (!dir.isDirectory()) {
279 LOGGER.error("Can't find or create directory {}", dir.getAbsolutePath());
280 return false;
281 } else {
282 dname = dirname;
283 }
284 } catch (Exception e) {
285 LOGGER.error("Can't find or create directory {}", dirname, e);
286 return false;
287 }
288 }
289 MCRCategory cl = DAO.getCategory(MCRCategoryID.rootID(id), -1);
290 Document classDoc = MCRCategoryTransformer.getMetaDataDocument(cl, false);
291
292 Transformer trans = getTransformer(style);
293 File xmlOutput = new File(dname, id + ".xml");
294 FileOutputStream out = new FileOutputStream(xmlOutput);
295 if (trans != null) {
296 StreamResult sr = new StreamResult(out);
297 trans.transform(new JDOMSource(classDoc), sr);
298 } else {
299 XMLOutputter xout = new XMLOutputter(Format.getPrettyFormat());
300 xout.output(classDoc, out);
301 out.flush();
302 }
303 LOGGER.info("Classifcation {} saved to {}.", id, xmlOutput.getCanonicalPath());
304 return true;
305 }
306
307
308
309
310
311
312
313
314
315
316
317 private static Transformer getTransformer(String style) throws TransformerFactoryConfigurationError,
318 TransformerConfigurationException {
319 String xslfile = DEFAULT_TRANSFORMER;
320 if (style != null && style.trim().length() != 0) {
321 xslfile = style + "-classification.xsl";
322 }
323 Transformer trans = null;
324
325 URL styleURL = MCRClassification2Commands.class.getResource("/" + xslfile);
326 if (styleURL == null) {
327 styleURL = MCRClassification2Commands.class.getResource(DEFAULT_TRANSFORMER);
328 }
329 if (styleURL != null) {
330 StreamSource source;
331 try {
332 source = new StreamSource(styleURL.toURI().toASCIIString());
333 } catch (URISyntaxException e) {
334 throw new TransformerConfigurationException(e);
335 }
336 TransformerFactory transfakt = TransformerFactory.newInstance();
337 transfakt.setURIResolver(MCRURIResolver.instance());
338 trans = transfakt.newTransformer(source);
339 }
340 return trans;
341 }
342
343
344
345
346
347
348
349
350
351
352
353 @MCRCommand(syntax = "export all classifications to directory {0} with {1}",
354 help = "The command store all classifications to the directory with name {0} with the stylesheet "
355 + "{1}-object.xsl. For {1} save is the default.",
356 order = 70)
357 public static boolean exportAll(String dirname, String style) throws Exception {
358 List<MCRCategoryID> allClassIds = DAO.getRootCategoryIDs();
359 boolean ret = false;
360 for (MCRCategoryID id : allClassIds) {
361 ret = ret & export(id.getRootID(), dirname, style);
362 }
363 return ret;
364 }
365
366
367
368
369 @MCRCommand(syntax = "list all classifications",
370 help = "The command list all classification stored in the database.",
371 order = 100)
372 public static void listAllClassifications() {
373 List<MCRCategoryID> allClassIds = DAO.getRootCategoryIDs();
374 for (MCRCategoryID id : allClassIds) {
375 LOGGER.info(id.getRootID());
376 }
377 LOGGER.info("");
378 }
379
380
381
382
383
384
385
386 @MCRCommand(syntax = "list classification {0}",
387 help = "The command list the classification with MCRObjectID {0}.",
388 order = 90)
389 public static void listClassification(String classid) {
390 MCRCategoryID clid = MCRCategoryID.rootID(classid);
391 MCRCategory cl = DAO.getCategory(clid, -1);
392 LOGGER.info(classid);
393 if (cl != null) {
394 listCategory(cl);
395 } else {
396 LOGGER.error("Can't find classification {}", classid);
397 }
398 }
399
400 private static void listCategory(MCRCategory categ) {
401 int level = categ.getLevel();
402 StringBuilder sb = new StringBuilder(128);
403 for (int i = 0; i < level * 2; i++) {
404 sb.append(' ');
405 }
406 String space = sb.toString();
407 if (categ.isCategory()) {
408 LOGGER.info("{} ID : {}", space, categ.getId().getID());
409 }
410 for (MCRLabel label : categ.getLabels()) {
411 LOGGER.info("{} Label : ({}) {}", space, label.getLang(), label.getText());
412 }
413 List<MCRCategory> children = categ.getChildren();
414 for (MCRCategory child : children) {
415 listCategory(child);
416 }
417 }
418
419 @MCRCommand(syntax = "repair category with empty labels",
420 help = "fixes all categories with no labels (adds a label with categid as @text for default lang)",
421 order = 110)
422 public static void repairEmptyLabels() {
423 EntityManager em = MCREntityManagerProvider.getCurrentEntityManager();
424 String deleteEmptyLabels = "delete from {h-schema}MCRCategoryLabels where text is null or trim(text) = ''";
425 int affected = em.createNativeQuery(deleteEmptyLabels).executeUpdate();
426 LOGGER.info("Deleted {} labels.", affected);
427 String sqlQuery = "select cat.classid,cat.categid from {h-schema}MCRCategory cat "
428 + "left outer join {h-schema}MCRCategoryLabels label on cat.internalid = label.category where "
429 + "label.text is null";
430 @SuppressWarnings("unchecked")
431 List<Object[]> list = em.createNativeQuery(sqlQuery).getResultList();
432
433 for (Object resultList : list) {
434 Object[] arrayOfResults = (Object[]) resultList;
435 String classIDString = (String) arrayOfResults[0];
436 String categIDString = (String) arrayOfResults[1];
437
438 MCRCategoryID mcrCategID = new MCRCategoryID(classIDString, categIDString);
439 MCRLabel mcrCategLabel = new MCRLabel(MCRConstants.DEFAULT_LANG, categIDString, null);
440 MCRCategoryDAOFactory.getInstance().setLabel(mcrCategID, mcrCategLabel);
441 LOGGER.info("fixing category with class ID \"{}\" and category ID \"{}\"", classIDString, categIDString);
442 }
443 LOGGER.info("Fixing category labels completed!");
444 }
445
446 @MCRCommand(syntax = "repair position in parent",
447 help = "fixes all categories gaps in position in parent",
448 order = 120)
449 @SuppressWarnings("unchecked")
450 public static void repairPositionInParent() {
451 EntityManager em = MCREntityManagerProvider.getCurrentEntityManager();
452
453 String sqlQuery = "select parentid, min(cat1.positioninparent+1) from {h-schema}MCRCategory cat1 "
454 + "where cat1.parentid is not null and not exists" + "(select 1 from {h-schema}MCRCategory cat2 "
455 + "where cat2.parentid=cat1.parentid and cat2.positioninparent=(cat1.positioninparent+1))"
456 + "and cat1.positioninparent not in "
457 + "(select max(cat3.positioninparent) from {h-schema}MCRCategory cat3 "
458 + "where cat3.parentid=cat1.parentid) group by cat1.parentid";
459
460 for (List<Object[]> parentWithErrorsList = em.createNativeQuery(sqlQuery)
461 .getResultList(); !parentWithErrorsList
462 .isEmpty();
463 parentWithErrorsList = em.createNativeQuery(sqlQuery).getResultList()) {
464 for (Object[] parentWithErrors : parentWithErrorsList) {
465 Number parentID = (Number) parentWithErrors[0];
466 Number firstErrorPositionInParent = (Number) parentWithErrors[1];
467 LOGGER.info("Category {} has the missing position {} ...", parentID, firstErrorPositionInParent);
468 repairCategoryWithGapInPos(parentID, firstErrorPositionInParent);
469 LOGGER.info("Fixed position {} for category {}.", firstErrorPositionInParent, parentID);
470 }
471 }
472
473 sqlQuery = "select parentid, min(cat1.positioninparent-1) from {h-schema}MCRCategory cat1 "
474 + "where cat1.parentid is not null and not exists" + "(select 1 from {h-schema}MCRCategory cat2 "
475 + "where cat2.parentid=cat1.parentid and cat2.positioninparent=(cat1.positioninparent-1))"
476 + "and cat1.positioninparent not in "
477 + "(select max(cat3.positioninparent) from {h-schema}MCRCategory cat3 "
478 + "where cat3.parentid=cat1.parentid) and cat1.positioninparent > 0 group by cat1.parentid";
479
480 while (true) {
481 List<Object[]> parentWithErrorsList = em.createNativeQuery(sqlQuery).getResultList();
482
483 if (parentWithErrorsList.isEmpty()) {
484 break;
485 }
486
487 for (Object[] parentWithErrors : parentWithErrorsList) {
488 Number parentID = (Number) parentWithErrors[0];
489 Number wrongStartPositionInParent = (Number) parentWithErrors[1];
490 LOGGER.info("Category {} has the the starting position {} ...", parentID, wrongStartPositionInParent);
491 repairCategoryWithWrongStartPos(parentID, wrongStartPositionInParent);
492 LOGGER.info("Fixed position {} for category {}.", wrongStartPositionInParent, parentID);
493 }
494 }
495 LOGGER.info("Repair position in parent finished!");
496 }
497
498 public static void repairCategoryWithWrongStartPos(Number parentID, Number wrongStartPositionInParent) {
499 EntityManager em = MCREntityManagerProvider.getCurrentEntityManager();
500 String sqlQuery = "update {h-schema}MCRCategory set positioninparent= positioninparent -"
501 + wrongStartPositionInParent
502 + "-1 where parentid=" + parentID + " and positioninparent > " + wrongStartPositionInParent;
503
504 em.createNativeQuery(sqlQuery).executeUpdate();
505 }
506
507 private static void repairCategoryWithGapInPos(Number parentID, Number firstErrorPositionInParent) {
508 EntityManager em = MCREntityManagerProvider.getCurrentEntityManager();
509
510
511
512
513
514 String sqlQuery = "update {h-schema}MCRCategory "
515 + "set positioninparent=(positioninparent - (select min(positioninparent) from "
516 + "{h-schema}MCRCategory where parentid="
517 + parentID
518 + " and positioninparent > "
519 + firstErrorPositionInParent
520 + ")+"
521 + firstErrorPositionInParent
522 + ") where parentid=" + parentID + " and positioninparent > " + firstErrorPositionInParent;
523
524 em.createNativeQuery(sqlQuery).executeUpdate();
525 }
526
527 @MCRCommand(syntax = "repair left right values for classification {0}",
528 help = "fixes all left and right values in the given classification",
529 order = 130)
530 public static void repairLeftRightValue(String classID) {
531 if (!(DAO instanceof MCRCategoryDAOImpl)) {
532 LOGGER.error("Command not compatible with {}", DAO.getClass().getName());
533 return;
534 }
535 ((MCRCategoryDAOImpl) DAO).repairLeftRightValue(classID);
536 }
537
538 @MCRCommand(syntax = "check all classifications",
539 help = "checks if all redundant information are stored without conflicts",
540 order = 140)
541 public static List<String> checkAllClassifications() {
542 List<MCRCategoryID> classifications = MCRCategoryDAOFactory.getInstance().getRootCategoryIDs();
543 List<String> commands = new ArrayList<>(classifications.size());
544 for (MCRCategoryID id : classifications) {
545 commands.add("check classification " + id.getRootID());
546 }
547 return commands;
548 }
549
550 @MCRCommand(syntax = "check classification {0}",
551 help = "checks if all redundant information are stored without conflicts",
552 order = 150)
553 public static void checkClassification(String id) {
554 LOGGER.info("Checking classifcation {}", id);
555 ArrayList<String> log = new ArrayList<>();
556 LOGGER.info("{}: checking for missing parentID", id);
557 checkMissingParent(id, log);
558 LOGGER.info("{}: checking for empty labels", id);
559 checkEmptyLabels(id, log);
560 if (log.isEmpty()) {
561 MCRCategoryImpl category = (MCRCategoryImpl) MCRCategoryDAOFactory.getInstance().getCategory(
562 MCRCategoryID.rootID(id), -1);
563 LOGGER.info("{}: checking left, right and level values and for non-null children", id);
564 checkLeftRightAndLevel(category, 0, 0, log);
565 }
566 if (log.size() > 0) {
567 LOGGER.error("Some errors occured on last test, report will follow");
568 StringBuilder sb = new StringBuilder();
569 for (String msg : log) {
570 sb.append(msg).append('\n');
571 }
572 LOGGER.error("Error report for classification {}\n{}", id, sb);
573 } else {
574 LOGGER.info("Classifcation {} has no errors.", id);
575 }
576 }
577
578 @MCRCommand(syntax = "remove unmapped categories from classification {0}",
579 help = "Deletes all Categories of classification {0} which can not be mapped from all other classifications!",
580 order = 160)
581 public static void filterClassificationWithMapping(String id) {
582 new MCRUnmappedCategoryRemover(id).filter();
583 }
584
585 private static int checkLeftRightAndLevel(MCRCategoryImpl category, int leftStart, int levelStart,
586 List<String> log) {
587 int curValue = leftStart;
588 final int nextLevel = levelStart + 1;
589 if (leftStart != category.getLeft()) {
590 log.add("LEFT of " + category.getId() + " is " + category.getLeft() + " should be " + leftStart);
591 }
592 if (levelStart != category.getLevel()) {
593 log.add("LEVEL of " + category.getId() + " is " + category.getLevel() + " should be " + levelStart);
594 }
595 int position = 0;
596 for (MCRCategory child : category.getChildren()) {
597 if (child == null) {
598 log.add("NULL child of parent " + category.getId() + " on position " + position);
599 continue;
600 }
601 LOGGER.debug(child.getId());
602 curValue = checkLeftRightAndLevel((MCRCategoryImpl) child, ++curValue, nextLevel, log);
603 position++;
604 }
605 ++curValue;
606 if (curValue != category.getRight()) {
607 log.add("RIGHT of " + category.getId() + " is " + category.getRight() + " should be " + curValue);
608 }
609 return curValue;
610 }
611
612 private static void checkEmptyLabels(String classID, List<String> log) {
613 EntityManager em = MCREntityManagerProvider.getCurrentEntityManager();
614 String sqlQuery = "select cat.categid from {h-schema}MCRCategory cat "
615 + "left outer join {h-schema}MCRCategoryLabels label on cat.internalid = label.category where "
616 + "cat.classid='"
617 + classID + "' and (label.text is null or trim(label.text) = '')";
618 @SuppressWarnings("unchecked")
619 List<String> list = em.createNativeQuery(sqlQuery).getResultList();
620
621 for (String categIDString : list) {
622 log.add("EMPTY lables for category " + new MCRCategoryID(classID, categIDString));
623 }
624 }
625
626 private static void checkMissingParent(String classID, List<String> log) {
627 EntityManager em = MCREntityManagerProvider.getCurrentEntityManager();
628 String sqlQuery = "select cat.categid from {h-schema}MCRCategory cat WHERE cat.classid='"
629 + classID + "' and cat.level > 0 and cat.parentID is NULL";
630 @SuppressWarnings("unchecked")
631 List<String> list = em.createNativeQuery(sqlQuery).getResultList();
632
633 for (String categIDString : list) {
634 log.add("parentID is null for category " + new MCRCategoryID(classID, categIDString));
635 }
636 }
637
638 @MCRCommand(syntax = "repair missing parent for classification {0}",
639 help = "restores parentID from information in the given classification, if left right values are correct",
640 order = 130)
641 public static void repairMissingParent(String classID) {
642 EntityManager em = MCREntityManagerProvider.getCurrentEntityManager();
643 String sqlQuery = "update {h-schema}MCRCategory cat set cat.parentID=(select parent.internalID from "
644 + "{h-schema}MCRCategory parent WHERE parent.classid='"
645 + classID
646 + "' and parent.leftValue<cat.leftValue and parent.rightValue>cat.rightValue and "
647 + "parent.level=(cat.level-1)) WHERE cat.classid='"
648 + classID + "' and cat.level > 0 and cat.parentID is NULL";
649 int updates = em.createNativeQuery(sqlQuery).executeUpdate();
650 LOGGER.info(() -> "Repaired " + updates + " parentID columns for classification " + classID);
651 }
652 }