001: package com.opensymphony.workflow.designer;
002:
003: import java.awt.*;
004: import java.awt.event.WindowAdapter;
005: import java.awt.event.WindowEvent;
006: import java.io.File;
007: import java.io.InputStream;
008: import java.io.PrintWriter;
009: import java.net.MalformedURLException;
010: import java.net.URL;
011: import java.util.Locale;
012: import java.util.ResourceBundle;
013: import java.util.StringTokenizer;
014: import javax.swing.*;
015: import javax.xml.parsers.DocumentBuilder;
016: import javax.xml.parsers.DocumentBuilderFactory;
017: import javax.xml.parsers.ParserConfigurationException;
018:
019: import com.opensymphony.workflow.FactoryException;
020: import com.opensymphony.workflow.InvalidWorkflowDescriptorException;
021: import com.opensymphony.workflow.config.WorkspaceManager;
022: import com.opensymphony.workflow.designer.dialogs.NewWorkspaceDialog;
023: import com.opensymphony.workflow.designer.editor.*;
024: import com.opensymphony.workflow.designer.swing.*;
025: import com.opensymphony.workflow.designer.swing.status.StatusBar;
026: import com.opensymphony.workflow.loader.*;
027: import org.jgraph.event.*;
028: import org.w3c.dom.Document;
029: import org.w3c.dom.Element;
030:
031: /**
032: * @author Hani Suleiman (hani@formicary.net) Date: May 15, 2003 Time: 8:36:20 PM
033: */
034: public class WorkflowDesigner extends JFrame implements
035: GraphSelectionListener, GraphModelListener {
036: public static final String WORKSPACE_SUFFIX = ".wsf";
037:
038: private WorkspaceNavigator navigator;
039: private WorkspaceManager manager = new WorkspaceManager();
040: private GraphTabbedPane graphTabs = new GraphTabbedPane();
041: private DesignerService service = null;
042: private JSplitPane mainSplitPane;
043: private EmptyBorderSplitPane leftSplitPane;
044: private CardPanel detailPanel = new CardPanel();
045: private FramePanel detailFramePanel;
046: private Object currentDetailObject = null;
047: public static WorkflowDesigner INSTANCE = null;
048: private PaletteDescriptor palette = null;
049: public StatusBar statusBar;
050:
051: public WorkflowDesigner(Splash splash) {
052: super (ResourceManager.getString("app.name"));
053: INSTANCE = this ;
054:
055: service = new DesignerService();
056:
057: setJMenuBar(BarFactory
058: .createMenubar(manager, service.getVerb()));
059: splash.setProgress(30);
060: navigator = new WorkspaceNavigator(this );
061: JScrollPane sp = new JScrollPane(detailPanel,
062: JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
063: JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
064: detailFramePanel = new FramePanel(ResourceManager
065: .getString("details"), false);
066: detailFramePanel.setContent(sp);
067:
068: splash.setProgress(40);
069: loadPalette();
070: splash.setProgress(50);
071:
072: // create workspace view
073: FramePanel flowsPanel = new FramePanel(ResourceManager
074: .getString("workspace"), false);
075: flowsPanel.setContent(new JScrollPane(navigator));
076:
077: // layout
078: leftSplitPane = new EmptyBorderSplitPane(
079: JSplitPane.VERTICAL_SPLIT, flowsPanel, detailFramePanel);
080: mainSplitPane = new EmptyBorderSplitPane(
081: JSplitPane.HORIZONTAL_SPLIT, leftSplitPane, graphTabs);
082: graphTabs.setVisible(false);
083:
084: splash.setProgress(60);
085: //Provide a preferred size for the split pane
086: String bounds = Prefs.INSTANCE.get(Prefs.DESIGNER_BOUNDS,
087: "100, 100, 800, 600");
088: StringTokenizer tok = new StringTokenizer(bounds, ",");
089: int x = Integer.parseInt(tok.nextToken().trim());
090: int y = Integer.parseInt(tok.nextToken().trim());
091: int w = Integer.parseInt(tok.nextToken().trim());
092: int h = Integer.parseInt(tok.nextToken().trim());
093: setLocation(x, y);
094: getContentPane().setLayout(new BorderLayout());
095: getContentPane().add(BarFactory.createToolbar(),
096: BorderLayout.NORTH);
097: splash.setProgress(65);
098: getContentPane().add(mainSplitPane, BorderLayout.CENTER);
099: statusBar = BarFactory.createStatusBar();
100: getContentPane().add(statusBar, BorderLayout.SOUTH);
101:
102: splash.setProgress(70);
103: mainSplitPane.setPreferredSize(new Dimension(w, h));
104:
105: addWindowListener(new WindowAdapter() {
106: public void windowClosing(WindowEvent evt) {
107: quit();
108: }
109: });
110: if ("new".equals(service.getVerb())) {
111: newRemoteWorkspace();
112: } else if ("modify".equals(service.getVerb())) {
113: newRemoteWorkspace();
114: openRemoteWorkspace();
115: } else {
116: String lastOpened = Prefs.INSTANCE.get(
117: Prefs.LAST_WORKSPACE, null);
118: if (lastOpened != null) {
119: try {
120: if (lastOpened.indexOf(":/") == -1) {
121: openWorkspace(new File(lastOpened).toURL());
122: } else {
123: openWorkspace(new URL(lastOpened));
124: }
125: } catch (MalformedURLException e) {
126: e.printStackTrace();
127: }
128: String workflow = Prefs.INSTANCE.get(
129: Prefs.WORKFLOW_CURRENT, null);
130: if (workflow != null) {
131: navigator.selectWorkflow(workflow);
132: }
133: }
134: }
135: mainSplitPane.setDividerLocation(Prefs.INSTANCE.getInt(
136: Prefs.MAIN_DIVIDER_LOCATION, 150));
137: leftSplitPane.setDividerLocation(Prefs.INSTANCE.getInt(
138: Prefs.DETAIL_DIVIDER_LOCATION, 50));
139: }
140:
141: public void graphChanged(GraphModelEvent e) {
142: if (detailPanel.getVisibleCard() == null) {
143: return;
144: }
145:
146: AbstractDescriptor desc = ((DetailPanel) detailPanel
147: .getVisibleCard()).getDescriptor();
148: showDetails(desc);
149: //DefaultGraphCell relationsCell = relationshipsNavigator.getCell();
150:
151: //Object[] cells = e.getChange().getChanged();
152: //System.out.println("changed = " + java.util.Arrays.asList(cells));
153: //for(int i = 0; i < cells.length; i++)
154: //{
155: // if(cells[i] instanceof WorkflowCell)
156: // {
157: // if(cells[i] == detailCell)
158: // {
159: // showDetails(desc);
160: // }
161: // }
162: // else if(cells[i] instanceof WorkflowEdge)
163: // {
164: // if(cells[i] == detailEdge)
165: // {
166: // showDetails(desc);
167: // }
168: // }
169: // if(cells[i] instanceof DefaultGraphCell)
170: // {
171: // //showRelationships(relationsCell);
172: // }
173: //}
174: }
175:
176: public WorkflowGraph getCurrentGraph() {
177: return graphTabs.getCurrentGraph();
178: }
179:
180: /**
181: * Delete a workflow from config
182: */
183: public void deleteWorkflow(String workflowName)
184: throws FactoryException {
185: graphTabs.removeGraph(workflowName);
186: navigator.removeWorkflow(workflowName);
187: manager.getCurrentWorkspace().removeWorkflow(workflowName);
188: }
189:
190: /**
191: * Delete a workflow from config
192: */
193: public void closeWorkflow(String workflowName)
194: throws FactoryException {
195: graphTabs.removeGraph(workflowName);
196: navigator.removeWorkflow(workflowName);
197: manager.getCurrentWorkspace().removeWorkflow(workflowName);
198: }
199:
200: public void validateCurrentWorkflow() {
201: WorkflowGraph graph = graphTabs.getCurrentGraph();
202: if (graph != null) {
203: validateWorkflow(graph.getName());
204: }
205: }
206:
207: public void validateSaveCurrentWorkflow() {
208: WorkflowGraph graph = graphTabs.getCurrentGraph();
209: if (graph != null) {
210: save(graph, true);
211: }
212: }
213:
214: public void validateWorkflow(String workflowName) {
215: WorkflowGraph graph = graphTabs.getGraph(workflowName);
216: if (graph != null) {
217: workflowName = graph.getName();
218: WorkflowDescriptor d = graph.getDescriptor();
219: if (d != null) {
220: try {
221: d.validate();
222: } catch (InvalidWorkflowDescriptorException e) {
223: System.out.println("Error validating workflow: "
224: + e);
225: JOptionPane.showMessageDialog(this , ResourceManager
226: .getString("error.validate.workflow",
227: new Object[] { e.getMessage() }),
228: ResourceManager.getString(
229: "title.validate.workflow",
230: new Object[] { workflowName }),
231: JOptionPane.ERROR_MESSAGE);
232: return;
233: }
234:
235: JOptionPane.showMessageDialog(this , ResourceManager
236: .getString("success.validate.workflow"),
237: ResourceManager.getString(
238: "title.validate.workflow",
239: new Object[] { workflowName }),
240: JOptionPane.INFORMATION_MESSAGE);
241: }
242: }
243: }
244:
245: public void closeCurrentWorkflow() {
246: WorkflowGraph graph = graphTabs.getCurrentGraph();
247: if (graph != null) {
248: graphTabs.removeGraph(graph);
249: }
250: //graphTabs.removeGraph(graphTabs.getCurrentGraph());
251: }
252:
253: public void createGraph(String workflowName) {
254: //Workspace currentWorkspace = manager.getCurrentWorkspace();
255: WorkflowFactory currentWorkspace = manager
256: .getCurrentWorkspace();
257: Layout layout = (Layout) currentWorkspace
258: .getLayout(workflowName);
259: WorkflowGraphModel model = new WorkflowGraphModel(layout);
260: model.setPalette(palette);
261: model.addGraphModelListener(this );
262: boolean hasLayout = layout != null;
263: if (layout == null)
264: layout = new Layout();
265: WorkflowDescriptor descriptor;
266: try {
267: descriptor = currentWorkspace.getWorkflow(workflowName,
268: false);
269: } catch (FactoryException e) {
270: e.printStackTrace();
271: return;
272: }
273: WorkflowGraph graph = new WorkflowGraph(model, descriptor,
274: layout, !hasLayout);
275: graph.addGraphSelectionListener(this );
276: //graph.setName(workflowName);
277: if (descriptor != null)
278: graph.setName(descriptor.getName());
279: else
280: graph.setName(workflowName);
281: graphTabs.addGraph(graph);
282: graphTabs.setVisible(true);
283: }
284:
285: public void quit() {
286: Point location = getLocation();
287: Prefs.INSTANCE.putInt(Prefs.MAIN_DIVIDER_LOCATION,
288: mainSplitPane.getDividerLocation());
289: Prefs.INSTANCE.putInt(Prefs.DETAIL_DIVIDER_LOCATION,
290: leftSplitPane.getDividerLocation());
291: Prefs.INSTANCE.put(Prefs.DESIGNER_BOUNDS, location.x + ","
292: + location.y + ',' + mainSplitPane.getWidth() + ','
293: + mainSplitPane.getHeight());
294: try {
295: Prefs.INSTANCE.flush();
296: } catch (Exception e) {
297: e.printStackTrace();
298: }
299: System.exit(0);
300: }
301:
302: public void renameWorkflow(String oldName, String newName) {
303: graphTabs.renameGraph(oldName, newName);
304: manager.getCurrentWorkspace().renameWorkflow(oldName, newName);
305: }
306:
307: public void valueChanged(GraphSelectionEvent e) {
308: Object lastAdded = null;
309: for (int i = e.getCells().length - 1; i >= 0; i--) {
310: if (e.isAddedCell(i)) {
311: lastAdded = e.getCells()[i];
312: break;
313: }
314: }
315: if (lastAdded instanceof WorkflowCell
316: || lastAdded instanceof WorkflowEdge) {
317: AbstractDescriptor desc = getCellDescriptor(lastAdded);
318: showDetails(desc);
319: navigator.selectTreeNode(graphTabs.getCurrentGraph()
320: .getDescriptor(), desc);
321: }
322: }
323:
324: public void showSelectedCellDetails() {
325: WorkflowGraph graph = graphTabs.getCurrentGraph();
326: if (graph != null) {
327: Object cell = graph.getSelectionModel().getSelectionCell();
328: if (cell != null) {
329: if (cell instanceof WorkflowCell
330: || cell instanceof WorkflowEdge) {
331: AbstractDescriptor desc = getCellDescriptor(cell);
332: showDetails(desc);
333: navigator.selectTreeNode(graphTabs
334: .getCurrentGraph().getDescriptor(), desc);
335: }
336: }
337: }
338: }
339:
340: public void refreshUI() {
341: if (currentDetailObject != null) {
342: showDetails(currentDetailObject);
343: }
344: }
345:
346: public void showDetails(Object node) {
347: if (node == null)
348: return;
349: String title = getDescriptorTitle(node);
350: AbstractDescriptor descriptor = null;
351: currentDetailObject = node;
352: String panelName = node.getClass().getName();
353: DetailPanel current = (DetailPanel) detailPanel
354: .getVisibleCard();
355: if (current != null)
356: current.closeView();
357: DetailPanel panel = (DetailPanel) detailPanel
358: .showCard(panelName);
359: if (panel == null) {
360: if (node instanceof StepDescriptor) {
361: panel = new StepEditor();
362: } else if (node instanceof SplitDescriptor) {
363: panel = new SplitEditor();
364: } else if (node instanceof JoinDescriptor) {
365: panel = new JoinEditor();
366: } else if (node instanceof ResultDescriptor) {
367: panel = new ResultEditor();
368: } else if (node instanceof ActionDescriptor) {
369: panel = new ActionEditor();
370: } else if (node instanceof WorkflowDescriptor) {
371: panel = new WorkflowEditor();
372: } else if (node instanceof String) {
373: panel = new GenericEditor();
374: }
375: }
376:
377: if (panel != null) {
378: if (node instanceof String) {
379: ((GenericEditor) panel).setLabel((String) node);
380: panel.setName(panelName);
381: detailFramePanel.setTitle(ResourceManager
382: .getString("details")
383: + (title != null ? (" - " + title) : ""));
384: detailPanel.showCard(panel);
385: return;
386: }
387: descriptor = (AbstractDescriptor) node;
388: }
389:
390: if (panel != null) {
391: WorkflowGraph currentGraph = graphTabs.getCurrentGraph();
392: if (currentGraph == null)
393: return;
394: panel.setModel(currentGraph.getWorkflowGraphModel());
395: panel.setGraph(currentGraph);
396: panel.setDescriptor(descriptor);
397:
398: detailFramePanel.setTitle(ResourceManager
399: .getString("details")
400: + (title != null ? (" - " + title) : ""));
401: detailPanel.showCard(panel);
402: } else {
403: System.out.println("WARN: no detail panel for "
404: + node.getClass());
405: }
406: }
407:
408: public void openWorkspace(URL file) {
409: if (file != null) {
410: String oldWorkspace = Prefs.INSTANCE.get(
411: Prefs.LAST_WORKSPACE, null);
412: try {
413: graphTabs.removeAll();
414: Prefs.INSTANCE.put(Prefs.LAST_WORKSPACE, file
415: .toString());
416: manager.loadWorkspace(file);
417: WorkflowFactory workspace = manager
418: .getCurrentWorkspace();
419: navigator.setWorkspace(workspace);
420: String[] workflows = workspace.getWorkflowNames();
421: for (int i = 0; i < workflows.length; i++) {
422: createGraph(workflows[i]);
423: }
424: Prefs.INSTANCE.put(Prefs.LAST_WORKSPACE, file
425: .toString());
426: } catch (Exception t) {
427: if (!file.toString().equals(oldWorkspace))
428: Prefs.INSTANCE.put(Prefs.LAST_WORKSPACE,
429: oldWorkspace);
430: else
431: Prefs.INSTANCE.remove(Prefs.LAST_WORKSPACE);
432: t.printStackTrace();
433: }
434: }
435: }
436:
437: public void openRemoteWorkspace() {
438: try {
439: manager.loadServiceWorkspace(service);
440: RemoteWorkspace workspace = (RemoteWorkspace) manager
441: .getCurrentWorkspace();
442: navigator.setWorkspace(workspace);
443: String[] workflows = workspace.getWorkflowNames();
444: for (int i = 0; i < workflows.length; i++) {
445: createGraph(workflows[i]);
446: }
447: } catch (Exception t) {
448: t.printStackTrace();
449: }
450: }
451:
452: public void checkWorkspaceExists() {
453: if (manager.getCurrentWorkspace() == null) {
454: NewWorkspaceDialog newSpace = new NewWorkspaceDialog(this ,
455: ResourceManager.getString("workspace.new"), true);
456: newSpace.pack();
457: newSpace.getBanner().setTitle("");
458: newSpace.getBanner().setSubtitle(
459: ResourceManager.getString("workspace.new.long"));
460: Utils.centerComponent(this , newSpace);
461: Dimension size = newSpace.getSize();
462: newSpace.setSize(size.width + 10, size.height + 15);
463: newSpace.setVisible(true);
464: }
465: }
466:
467: private boolean save(WorkflowGraph graph, boolean validate) {
468: boolean saved = false;
469:
470: Layout layout = graph.getGraphLayout();
471: WorkflowGraphModel model = (WorkflowGraphModel) graph
472: .getModel();
473: layout.setAllEntries(model.getActivitiesList());
474: String workflowName = graph.getName();
475: manager.getCurrentWorkspace().setLayout(workflowName, layout);
476: WorkflowDescriptor descriptor = null;
477: try {
478: descriptor = manager.getCurrentWorkspace().getWorkflow(
479: workflowName);
480: if (validate) {
481: descriptor.validate();
482: }
483: if (manager.getCurrentWorkspace() instanceof Workspace)
484: saved = ((Workspace) manager.getCurrentWorkspace())
485: .saveWorkflow(workflowName, descriptor, graph,
486: true);
487: else if (manager.getCurrentWorkspace() instanceof RemoteWorkspace)
488: saved = ((RemoteWorkspace) manager
489: .getCurrentWorkspace()).saveWorkflow(
490: workflowName, descriptor, graph, true);
491: if (!saved) {
492: JOptionPane.showMessageDialog(this , "Error",
493: ResourceManager.getString(
494: "error.save.workflow.long",
495: new Object[] { workflowName }),
496: JOptionPane.ERROR_MESSAGE);
497: }
498: } catch (InvalidWorkflowDescriptorException e) {
499: System.out.println("Error saving workflow: " + e);
500: PrintWriter out = new PrintWriter(System.out);
501: descriptor.writeXML(out, 0);
502: out.flush();
503: JOptionPane.showMessageDialog(this , ResourceManager
504: .getString("error.validate.workflow",
505: new Object[] { e.getMessage() }),
506: ResourceManager.getString(
507: "title.validate.workflow",
508: new Object[] { workflowName }),
509: JOptionPane.ERROR_MESSAGE);
510: } catch (Exception e) {
511: e.printStackTrace();
512: JOptionPane.showMessageDialog(this , e.getMessage(),
513: ResourceManager.getString(
514: "error.save.workflow.long",
515: new Object[] { workflowName }),
516: JOptionPane.ERROR_MESSAGE);
517: }
518: return saved;
519: }
520:
521: public void saveOpenGraphs() {
522: WorkflowGraph[] graphs = graphTabs.getGraphs();
523: for (int i = 0; i < graphs.length; i++) {
524: save(graphs[i], false);
525: }
526: }
527:
528: public void saveWorkspace() {
529: manager.saveWorkspace();
530: }
531:
532: public Workspace newLocalWorkspace() {
533: closeWorkspace();
534: Workspace workspace = new Workspace();
535: manager.setCurrentWorkspace(workspace);
536: navigator.setWorkspace(workspace);
537: return workspace;
538: }
539:
540: public RemoteWorkspace newRemoteWorkspace() {
541: closeWorkspace();
542: RemoteWorkspace workspace = new RemoteWorkspace(service);
543: manager.setCurrentWorkspace(workspace);
544: navigator.setWorkspace(workspace);
545: return workspace;
546: }
547:
548: public void closeWorkspace() {
549: //don't bother doing anything if we have no workspace visible
550: if (!graphTabs.isVisible())
551: return;
552: graphTabs.removeAll();
553: manager.setCurrentWorkspace(null);
554: navigator.setWorkspace(null);
555: Prefs.INSTANCE.remove(Prefs.LAST_WORKSPACE);
556: graphTabs.setVisible(false);
557: }
558:
559: public void newWorkflowCreated(String name) {
560: navigator.addWorkflow(name);
561: navigator.selectWorkflow(name);
562: }
563:
564: public WorkspaceNavigator navigator() {
565: return navigator;
566: }
567:
568: public void selectWorkflow(String workflowName) {
569: if (graphTabs.selectWorkflow(workflowName)) {
570: Prefs.INSTANCE.put(Prefs.WORKFLOW_CURRENT, workflowName);
571: return;
572: }
573: createGraph(workflowName);
574: Prefs.INSTANCE.put(Prefs.WORKFLOW_CURRENT, workflowName);
575: graphTabs.setVisible(true);
576: }
577:
578: public void selectCell(AbstractDescriptor descriptor) {
579: WorkflowGraph graph = getCurrentGraph();
580: graph.getSelectionModel().clearSelection();
581: WorkflowGraphModel model = (WorkflowGraphModel) graph
582: .getModel();
583: if (descriptor instanceof StepDescriptor) {
584: StepCell cell = model.getStepCell(descriptor.getId());
585: if (cell != null) {
586: graph.getSelectionModel().setSelectionCell(cell);
587: }
588: } else if (descriptor instanceof SplitDescriptor) {
589: SplitCell cell = model.getSplitCell(descriptor.getId());
590: if (cell != null) {
591: graph.getSelectionModel().setSelectionCell(cell);
592: }
593: } else if (descriptor instanceof JoinDescriptor) {
594: JoinCell cell = model.getJoinCell(descriptor.getId());
595: if (cell != null) {
596: graph.getSelectionModel().setSelectionCell(cell);
597: }
598: } else if (descriptor instanceof ResultDescriptor) {
599: ResultEdge edge = model
600: .getResultCell((ResultDescriptor) descriptor);
601: if (edge != null) {
602: graph.getSelectionModel().setSelectionCell(edge);
603: }
604: }
605: }
606:
607: private void loadPalette() {
608: try {
609: DocumentBuilderFactory dbf = DocumentBuilderFactory
610: .newInstance();
611: dbf.setNamespaceAware(true);
612:
613: DocumentBuilder db = null;
614:
615: try {
616: db = dbf.newDocumentBuilder();
617: } catch (ParserConfigurationException e) {
618: e.printStackTrace();
619: System.exit(1);
620: }
621:
622: InputStream is = WorkflowDesigner.class
623: .getResourceAsStream("/META-INF/palette.xml");
624: Document doc = db.parse(is);
625: ResourceBundle bundle = ResourceBundle.getBundle(
626: "META-INF/palette", Locale.getDefault(), getClass()
627: .getClassLoader());
628: Element root = (Element) doc.getElementsByTagName("plugin")
629: .item(0);
630:
631: palette = new PaletteDescriptor(root,
632: new EnhancedResourceBundle(bundle));
633: } catch (Exception e) {
634: e.printStackTrace();
635: }
636: }
637:
638: private AbstractDescriptor getCellDescriptor(Object cell) {
639: if (cell instanceof StepCell) {
640: return ((StepCell) cell).getDescriptor();
641: } else if (cell instanceof JoinCell) {
642: return ((JoinCell) cell).getJoinDescriptor();
643: } else if (cell instanceof SplitCell) {
644: return ((SplitCell) cell).getSplitDescriptor();
645: } else if (cell instanceof InitialActionCell) {
646: return ((InitialActionCell) cell).getActionDescriptor();
647: } else if (cell instanceof ResultEdge) {
648: return ((ResultEdge) cell).getDescriptor();
649: }
650: return null;
651: }
652:
653: private String getDescriptorTitle(Object desc) {
654: String title = "";
655: if (desc instanceof StepDescriptor) {
656: title = ((StepDescriptor) desc).getName();
657: } else if (desc instanceof SplitDescriptor) {
658: title = "Split #" + ((SplitDescriptor) desc).getId();
659: } else if (desc instanceof JoinDescriptor) {
660: title = "Join #" + ((JoinDescriptor) desc).getId();
661: } else if (desc instanceof ResultDescriptor) {
662: title = ((ResultDescriptor) desc).getDisplayName();
663: } else if (desc instanceof ActionDescriptor) {
664: title = ((ActionDescriptor) desc).getName();
665: } else if (desc instanceof WorkflowDescriptor) {
666: title = ((WorkflowDescriptor) desc).getName();
667: }
668: return title;
669: }
670: }
|