001package jmri.jmrix.openlcb.swing.eventtable;
002
003import java.beans.PropertyChangeEvent;
004import java.beans.PropertyChangeListener;
005import java.nio.charset.StandardCharsets;
006import java.io.*;
007import java.util.*;
008
009import javax.swing.*;
010import javax.swing.table.*;
011
012import jmri.*;
013import jmri.jmrix.openlcb.*;
014import jmri.jmrix.openlcb.swing.eventtable.configurexml.*;
015
016import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
017
018import org.apache.commons.csv.CSVFormat;
019import org.apache.commons.csv.CSVPrinter;
020import org.apache.commons.csv.CSVRecord;
021
022import org.openlcb.*;
023import org.openlcb.implementations.*;
024
025/**
026 * DataModel for the table in the EventTablePane
027 * <p>
028 * Persistence is handled via the 
029 * {@link jmri.jmrix.openlcb.swing.eventtable.configurexml} package.  Note that
030 * this class explicitly invokes persistence in the constructor and at shutdown;
031 * the general mechanism to store in panel files is not used here intentionally.
032 *
033 * @author Bob Jacobsen Copyright (C) 2023, 2026
034 * @since 5.17.3
035 */
036public class EventTableDataModel extends AbstractTableModel {
037
038    public EventTableDataModel(MimicNodeStore store, EventTable stdEventTable, OlcbEventNameStore nameStore) {
039        this.store = store;
040        this.stdEventTable = stdEventTable;
041        this.nameStore = nameStore;
042
043        // listen for updated AlsoKnownAs (AKA) content
044        stdEventTable.addPropertyChangeListener(eventInfoListener);
045        // load stored contents
046        loadNameStoreEventIDs();
047        
048        loadModelData();
049        
050        // arrange to store the current contents at shutdown
051        initShutdownTask();
052        
053    }
054
055    public static final int COL_EVENTID = 0;
056    public static final int COL_EVENTNAME = 1;
057    public static final int COL_TRIGGER = 2;
058    public static final int COL_PRODUCER_NODE = 3;
059    public static final int COL_PRODUCER_NAME = 4;
060    public static final int COL_CONSUMER_NODE = 5;
061    public static final int COL_CONSUMER_NAME = 6;
062    public static final int COL_CONTEXT_INFO = 7;
063    public static final int COL_COUNT = 8;
064
065    MimicNodeStore store;
066    EventTable stdEventTable;
067    OlcbEventNameStore nameStore;
068    IdTagManager tagManager;
069    public JTable table; // public for configurexml test
070    public TableRowSorter<EventTableDataModel> sorter; // public for configurexml test
071    boolean popcornModeActive = false;
072
073    PropertyChangeListener eventInfoListener = (java.beans.PropertyChangeEvent e) -> {
074            eventInfoChanged(e);
075        };
076    
077    PropertyChangeListener eventHandleListener = (java.beans.PropertyChangeEvent e) -> {
078            handleDescriptionChanged(e);
079        };
080        
081    // static so the data remains available through a window close-open cycle
082    // public to allow access for testing; clean that up sometime with subclassing
083    static final ArrayList<TripleMemo> memos = new ArrayList<>();
084
085    public static List<TripleMemo> getMemos() { return memos; }  // public for testing
086    
087    TripleMemo getTripleMemo(int row) {
088        if (row >= memos.size()) {
089            return null;
090        }
091        return memos.get(row);
092    }
093
094    public static void clearStatics() {
095        // to be only used for testing, this clears all class-static information
096        nameToNodeId.clear();
097        nodeIdToName.clear();
098        
099        eventToDescriptions.clear();
100        memos.clear();
101    }
102    
103    // ********************************
104    // Store for node name<->ID mapping
105    // ********************************
106 
107    private static final Map<String, NodeID> nameToNodeId = new HashMap<>();
108    private static final Map<NodeID, String> nodeIdToName = new HashMap<>();
109
110
111    /**
112     * @param nodeID The NodeID being searched for
113     * @return The name associated with that NodeID or an empty string
114     */
115    public String getNodeName(NodeID nodeID) {
116        var name = nodeIdToName.get(nodeID);
117        if (name == null || name.isEmpty()) return nodeID.toString();
118        return name;
119    }
120    
121    /**
122     * @param nodeID The NodeID being searched for
123     * @return true if there is an associated name
124     */
125    public boolean hasNodeName(NodeID nodeID) {
126        var name = nodeIdToName.get(nodeID);
127        if (name == null || name.isEmpty()) return false;
128        return true;
129    }
130
131    /**
132     * @param name The node name being searched for
133     * @return The NodeID associated with that name or a node ID constructed from the input
134     */
135    public NodeID getNodeID(String name) {
136        var nid = nameToNodeId.get(name);
137        if (nid == null) {
138            try {
139                return new NodeID(name);
140            } catch (NumberFormatException e) {
141                // not a proper format for a node ID, return null
142                return null;
143            }
144        }
145        return nid;    
146    }
147
148    /**
149     * @param name The node name being searched for
150     * @return true if an NodeID is associated with that name
151     */
152    public boolean hasNodeID(String name) {
153        var nid = nameToNodeId.get(name);
154        if (nid == null) return false;
155        return true;    
156    }
157
158    /**
159     * Create a new name to/from NodeID association.
160     * <p>
161     * If a previous association exits, this one is ignored.
162     *
163     * @param nodeID associated EventID
164     * @param name  associated name
165     */
166    public void addMatch(NodeID nodeID, String name) {
167        if (! (nameToNodeId.containsKey(name) || nodeIdToName.containsKey(nodeID) )) {
168            nameToNodeId.put(name, nodeID);
169            nodeIdToName.put(nodeID, name);
170            log.trace("setting dirty true");
171        }
172    }
173
174    /**
175     * Get all the NodeIDs available
176     * @return Set of all available NodeIDs
177     */
178    public java.util.Set<NodeID> getNodeIDMatches() {
179        return nodeIdToName.keySet();        
180    }
181
182    // ********************************
183    // Store for also-known-as event description information
184    // ********************************
185
186    private static final Map<EventID, Set<String>> eventToDescriptions = new HashMap<>(); // public for testing, fix that eventually
187
188    void updateAuxiliaryInformation(EventID event) {
189        log.debug("update/recreate event aka info for {}", event);
190        // make a new clear list
191        var list = new HashSet<String>();
192        eventToDescriptions.put(event, list);
193        // make sure the list contains all the available descriptions
194        for (var entry : stdEventTable.getEventInfo(event).getAllEntries()) {
195            log.trace("   added: {}", entry.getDescription());
196            list.add(entry.getDescription());
197        }
198    }
199
200    public void addAuxiliaryInformation(EventID event, String description){
201        var list = eventToDescriptions.get(event);
202        if (list == null) {
203            list = new HashSet<String>();
204            eventToDescriptions.put(event, list);
205        }
206        list.add(description);
207    }
208    
209    public Set<String> getAuxiliaryInformation(EventID event) {
210        var retval = eventToDescriptions.get(event);
211        if (retval == null) {
212            retval = new HashSet<String>();
213            eventToDescriptions.put(event, retval);
214        }
215        return retval;
216    }
217
218    public Set<EventID> getAuxiliaryKnownEvents() {
219        return eventToDescriptions.keySet();
220    }
221    
222    // ********************************
223
224    // invoked when EventTable.addEvent is called to add an eventID and description
225    protected void eventInfoChanged(PropertyChangeEvent e) {
226        log.trace("eventInfoChanged {} {}", e.getPropertyName(), e.getNewValue());
227        // looking for events added to the EventTable
228        if (e.getPropertyName().equals(EventTable.EVENT_ENTRY_ADDED)) {
229            // this is information added to an event
230            var holder = (EventTable.EventTableEntryHolder)e.getNewValue();
231            updateAuxiliaryInformation(holder.getEntry().getEvent());
232            // register a listener on the EventInfo from the holder
233            holder.getList().addPropertyChangeListener(eventHandleListener);
234            // and force update to get it drawn by the table
235            handleTableUpdate(-1, -1);  // this batches these
236        }
237    }
238
239    // invoked from EventTable when EventInfo has a description added or deleted,
240    // or a description is updated through the EventTableEntry 
241    protected void handleDescriptionChanged(PropertyChangeEvent e) {
242        switch (e.getPropertyName()) {
243            case EventTable.DESCRIPTION_ADDED :
244                handleDescriptionAdded(e);
245                break;
246            case EventTable.DESCRIPTION_ERASED :
247                handleDescriptionErased(e);
248                break;
249            case EventTable.DESCRIPTION_UPDATED :
250                handleDescriptionUpdated(e);
251                break;
252            
253            
254            default: // we ignore other notifications
255                break;
256        }
257    }
258        
259    void handleDescriptionAdded(PropertyChangeEvent e){
260        log.debug("handleDescriptionAdded: {}", e.getPropertyName());
261        var info = (EventTable.EventInfo)e.getNewValue();
262        updateAuxiliaryInformation(info.getEventId());        
263    }
264    
265    void handleDescriptionErased(PropertyChangeEvent e){
266        log.debug("handleDescriptionErased: {}", e.getPropertyName());
267        var info = (EventTable.EventInfo)e.getNewValue();
268        updateAuxiliaryInformation(info.getEventId());        
269    }
270    
271    void handleDescriptionUpdated(PropertyChangeEvent e){
272        log.debug("handleDescriptionUpdated: {}", e.getPropertyName());
273        var info = (EventTable.EventInfo)e.getNewValue();
274        updateAuxiliaryInformation(info.getEventId());        
275    }
276    
277    protected void loadNameStoreEventIDs() {
278        // are there events in the Name Store? If so, add them
279        log.debug("loadNameStoreEventIDs with {}", nameStore.getMatches().size());
280        for (var eventID: nameStore.getMatches()) {
281            log.trace("      checking {}", eventID);
282            var memo = new TripleMemo(
283                                eventID,
284                                "",
285                                null,
286                                "",
287                                null,
288                                ""
289                            );
290            // check to see if already in there:
291            boolean found = false;
292            for (var check : memos) {
293                if (memo.eventID.equals(check.eventID)) {
294                    found = true;
295                    log.trace("          found!");
296                    break;
297                }
298            }
299            if (! found) {
300                log.trace("          loading!");
301                memos.add(memo);
302            }
303        }
304    }
305
306
307    @Override
308    public Object getValueAt(int row, int col) {
309        if (row >= memos.size()) {
310            log.warn("request out of range: {} greater than {}", row, memos.size());
311            return "Illegal col "+row+" "+col;
312        }
313        var memo = memos.get(row);
314        switch (col) {
315            case COL_EVENTID: 
316                String retval = memo.eventID.toShortString();
317                if (!memo.rangeSuffix.isEmpty()) retval += " - "+memo.rangeSuffix;
318                return retval;
319            case COL_EVENTNAME:
320                if (nameStore.hasEventName(memo.eventID)) {
321                    return nameStore.getEventName(memo.eventID);
322                } else {
323                    return "";
324                }
325                
326            case COL_TRIGGER:
327                return Bundle.getMessage("TableColTrigger");
328            case COL_PRODUCER_NODE:
329                return memo.producer != null ? memo.producer.toString() : "";
330            case COL_PRODUCER_NAME: return memo.producerName;
331            case COL_CONSUMER_NODE:
332                return memo.consumer != null ? memo.consumer.toString() : "";
333            case COL_CONSUMER_NAME: return memo.consumerName;
334            case COL_CONTEXT_INFO:
335
336                // When table is constrained, these rows don't match up, need to find constrained row
337                var viewRow = sorter.convertRowIndexToView(row);
338
339                if (lineIncrement <= 0) { // load cache variable?
340                    if (viewRow >= 0) {
341                        lineIncrement = table.getRowHeight(viewRow); // do this if valid row
342                    } else {
343                        lineIncrement = table.getFont().getSize()*13/10; // line spacing from font if not valid row
344                    }
345                 }
346
347                var result = new StringBuilder();
348
349                var height = lineIncrement/3; // for margins
350                var first = true;   // no \n before first line
351
352                // interpret eventID and start with that if present
353                String interp = memo.eventID.parse();
354                if (interp != null && !interp.isEmpty()) {
355                    height += lineIncrement;
356                    result.append(interp);                        
357                    first = false;
358                }
359
360                // scan the CD/CDI information as available
361                for (var entry : getAuxiliaryInformation(memo.eventID)) {
362                    if (!first) result.append("\n");
363                    first = false;
364                    height += lineIncrement;
365                    result.append(entry);
366                }
367
368                // set height for multi-line output in the cell
369                if (viewRow >= 0) { // make sure it's a valid visible row in the table; -1 signals not
370                    // set height
371                    if (height < lineIncrement) {
372                        height = height+lineIncrement; // when no lines, assume 1
373                    }
374                    if (height != table.getRowHeight(viewRow)) { // avoid repeating repaint loop
375                        table.setRowHeight(viewRow, height);
376                    }
377                } else {
378                    lineIncrement = -1;  // reload on next request, hoping for a viewed row
379                }
380                return new String(result);
381            default: return "Illegal column at "+row+" "+col;
382        }
383    }
384
385    int lineIncrement = -1; // cache the line spacing for multi-line cells; 
386                            // this gets the value before any adjustments done
387
388    @Override
389    public void setValueAt(Object value, int row, int col) {
390        if (col == COL_EVENTNAME) {
391            if (row >= memos.size()) {
392                log.warn("request out of range: {} greater than {}", row, memos.size());
393                return;
394            }
395            var memo = memos.get(row);
396            nameStore.addMatch(memo.eventID, value.toString());
397            return;
398        } else if (col == COL_TRIGGER) {
399            var nodeMemo = memos.get(row);
400            var sysMemo = jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class);
401            var connection = sysMemo.get(org.openlcb.Connection.class);
402            var srcNodeID = sysMemo.get(org.openlcb.NodeID.class);
403            Message m = new ProducerConsumerEventReportMessage(srcNodeID, nodeMemo.eventID);
404            connection.put(m, null);
405            
406        }
407    }
408
409    @Override
410    public int getColumnCount() {
411        return COL_COUNT;
412    }
413
414    @Override
415    public String getColumnName(int col) {
416        switch (col) {
417            case COL_EVENTID:       return Bundle.getMessage("TableColEventId");
418            case COL_EVENTNAME:     return Bundle.getMessage("TableColEventName");
419            case COL_TRIGGER:       return Bundle.getMessage("TableColTrigger");
420            case COL_PRODUCER_NODE: return Bundle.getMessage("TableColProducerNode");
421            case COL_PRODUCER_NAME: return Bundle.getMessage("TableColProducerName");
422            case COL_CONSUMER_NODE: return Bundle.getMessage("TableColConsumerNode");
423            case COL_CONSUMER_NAME: return Bundle.getMessage("TableColConsumerName");
424            case COL_CONTEXT_INFO:  return Bundle.getMessage("TableColContextInfo");
425            default: return "ERROR "+col;
426        }
427    }
428
429    @Override
430    public int getRowCount() {
431        return memos.size();
432    }
433
434    @Override
435    public boolean isCellEditable(int row, int col) {
436        return col == COL_EVENTNAME || col == COL_TRIGGER;
437    }
438
439    @Override
440    public Class<?> getColumnClass(int col) {
441        if (col == COL_TRIGGER) {
442            return JButton.class;
443        } else {
444            return String.class;
445        }
446    }
447
448    /**
449     * Remove all existing data, generally just in advance of an update
450     */
451    @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") // Swing thread deconflicts
452    void clear() {
453        memos.clear();
454        fireTableDataChanged();  // don't queue this one, must be immediate
455    }
456
457    /**
458     * Remove all existing data from currently-present nodes, generally just in advance of an update
459     */
460    @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") // Swing thread deconflicts
461    void clearAllPresentNodes() {
462        var presentNodeIDs = new HashSet<NodeID>();
463        for (var nodeMemo : store.getNodeMemos()) {
464            presentNodeIDs.add(nodeMemo.getNodeID());
465        }
466
467        var memosToRemove = new ArrayList<TripleMemo>();
468        for (var tripleMemo : memos) {
469            if (presentNodeIDs.contains(tripleMemo.producer)) {
470                tripleMemo.producer = null;
471                tripleMemo.producerName = null;
472            }
473            if (presentNodeIDs.contains(tripleMemo.consumer)) {
474                tripleMemo.consumer = null;
475                tripleMemo.consumerName = null;
476            }
477            if (tripleMemo.producer == null && tripleMemo.consumer == null) {
478                memosToRemove.add(tripleMemo);
479            }
480        }
481        for (var tripleMemo : memosToRemove) {
482            memos.remove(tripleMemo);
483        }
484        
485        fireTableDataChanged();  // don't queue this one, must be immediate
486    }
487
488    /**
489     * Notify the table that the contents have changed.
490     * To reduce CPU load, this batches the changes
491     * @param start first row changed; -1 means entire table (not used yet)
492     * @param end   last row changed; -1 means entire table (not used yet)
493     */
494    void handleTableUpdate(int start, int end) {
495        if (log.isTraceEnabled()) { // check logging level to avoid processing irrelevant traceback
496            log.trace("handleTableUpdated", jmri.util.LoggingUtil.shortenStacktrace(new Exception("traceback")));
497        }
498        
499        final int DELAY = 500;
500
501        if (!pending) {
502            jmri.util.ThreadingUtil.runOnGUIDelayed(() -> {
503                pending = false;
504                log.debug("handleTableUpdated fires table changed");
505                fireTableDataChanged();
506            }, DELAY);
507            pending = true;
508        }
509
510    }
511    boolean pending = false;
512
513    private String chooseNodeName(NodeID nodeID) {
514        String name = "";
515
516        // see if in the current EventStore, done first to pick up changes
517        var nodeMemo = store.findNode(nodeID);
518        if (nodeMemo != null) {
519            var ident = nodeMemo.getSimpleNodeIdent();
520            if (ident != null) {
521                    name = ident.getUserName();
522                    // and remember this name for later
523                    addMatch(nodeID, name);
524                    
525                }
526        }
527        // otherwise  if node name already encountered
528        if (name.isEmpty() && hasNodeName(nodeID)) {
529            name = getNodeName(nodeID);
530        }
531
532        // if all else fails, use a name constructed from SNIP information
533        if (name.isEmpty() && nodeMemo != null) {
534            var ident = nodeMemo.getSimpleNodeIdent();
535            if (ident != null) {
536                name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion();
537            }
538        }
539
540        return name;
541    }
542
543    /**
544     * Record an event-producer pair
545     * @param eventID Observed event
546     * @param nodeID  Node that is known to produce the event
547     * @param rangeSuffix the range mask string or "" for single events
548     * @param pcer true if this Producer was inferred from a PCER message, false if from a Producer Identified message
549     */
550    public void recordProducer(EventID eventID, NodeID nodeID, String rangeSuffix, boolean pcer) {
551        log.debug("recordProducer of {} in {}", eventID, nodeID);
552
553        // update if the model has been cleared
554        if (memos.size() <= 1) {
555            handleTableUpdate(-1, -1);
556        }
557
558        String name = chooseNodeName(nodeID);
559
560        // if this already exists, skip storing it
561        // if you can, find a matching memo with an empty consumer value
562        TripleMemo empty = null;    // an existing empty cell                       // TODO: switch to int index for handle update below
563        TripleMemo bestEmpty = null;// an existing empty cell with matching consumer// TODO: switch to int index for handle update below
564        TripleMemo sameNodeID = null;// cell with matching consumer                 // TODO: switch to int index for handle update below
565        for (int i = 0; i < memos.size(); i++) {
566            var memo = memos.get(i);
567            if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) {
568                // if nodeID matches, already present; ignore
569                if (nodeID.equals(memo.producer)) {
570                    // The node ID is already registered (hence appearing in table)
571                    // for this producer.
572                    //
573                    // This might be the 2nd EventTablePane to process the data,
574                    // hence memos would already have been processed. To
575                    // handle that, need to fire a change to the table.
576                    //
577                    // On the other hand, this rapidly erases the
578                    // popcorn display, so we disable it for that.
579                    //
580                    // We also disable it if this call was from a PCER message,
581                    // as those are routine and should have been preceeded
582                    // by a Producer Identified. Leaving this in results in
583                    // excessive refreshes and e.g. frustrating loss of 
584                    // cell selections.
585                    //
586                    if (! (popcornModeActive | pcer) ) {
587                        handleTableUpdate(i, i);
588                    }
589                    return;
590                }
591                // if empty producer slot, remember it
592                if (memo.producer == null) {
593                    empty = memo;
594                    // best empty has matching consumer
595                    if (nodeID.equals(memo.consumer)) bestEmpty = memo;
596                }
597                // if same consumer slot, remember it
598                if (nodeID == memo.consumer) {
599                    sameNodeID = memo;
600                }
601            }
602        }
603
604        // can we use the bestEmpty?
605        if (bestEmpty != null) {
606            // yes
607            log.trace("   use bestEmpty");
608            bestEmpty.producer = nodeID;
609            bestEmpty.producerName = name;
610            handleTableUpdate(-1, -1); // TODO: should be rows for bestEmpty, bestEmpty
611            return;
612        }
613
614        // can we just insert into the empty?
615        if (empty != null && sameNodeID == null) {
616            // yes
617            log.trace("   reuse empty");
618            empty.producer = nodeID;
619            empty.producerName = name;
620            handleTableUpdate(-1, -1); // TODO: should be rows for empty, empty
621            return;
622        }
623
624        // is there a sameNodeID to insert into?
625        if (sameNodeID != null) {
626            // yes
627            log.trace("   switch to sameID");
628            var fromSaveNodeID = sameNodeID.producer;
629            var fromSaveNodeIDName = sameNodeID.producerName;
630            sameNodeID.producer = nodeID;
631            sameNodeID.producerName = name;
632            // now leave behind old cell to make new one in next block
633            nodeID = fromSaveNodeID;
634            name = fromSaveNodeIDName;
635        }
636
637        // have to make a new one
638        var memo = new TripleMemo(
639                        eventID,
640                        rangeSuffix,
641                        nodeID,
642                        name,
643                        null,
644                        ""
645                    );
646        memos.add(memo);
647        handleTableUpdate(memos.size()-1, memos.size()-1);
648    }
649
650    /**
651     * Record an event-consumer pair
652     * @param eventID Observed event
653     * @param nodeID  Node that is known to consume the event
654     * @param rangeSuffix the range mask string or "" for single events
655     */
656    public void recordConsumer(EventID eventID, NodeID nodeID, String rangeSuffix) {
657        log.debug("recordConsumer of {} in {}", eventID, nodeID);
658
659        // update if the model has been cleared
660        if (memos.size() <= 1) {
661            handleTableUpdate(-1, -1);
662        }
663
664        String name = chooseNodeName(nodeID);
665
666        // if this already exists, skip storing it
667        // if you can, find a matching memo with an empty consumer value
668        TripleMemo empty = null;    // an existing empty cell                       // TODO: switch to int index for handle update below
669        TripleMemo bestEmpty = null;// an existing empty cell with matching producer// TODO: switch to int index for handle update below
670        TripleMemo sameNodeID = null;// cell with matching consumer                 // TODO: switch to int index for handle update below
671        for (int i = 0; i < memos.size(); i++) {
672            var memo = memos.get(i);
673            if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) {
674                // if nodeID matches, already present; ignore
675                if (nodeID.equals(memo.consumer)) {
676                    // might be 2nd EventTablePane to process the data,
677                    // hence memos would already have been processed. To
678                    // handle that, always fire a change to the table.
679                    log.trace("    nodeDI == memo.consumer");
680                    handleTableUpdate(i, i);
681                    return;
682                }
683                // if empty consumer slot, remember it
684                if (memo.consumer == null) {
685                    empty = memo;
686                    // best empty has matching producer
687                    if (nodeID.equals(memo.producer)) bestEmpty = memo;
688                }
689                // if same producer slot, remember it
690                if (nodeID == memo.producer) {
691                    sameNodeID = memo;
692                }
693            }
694        }
695
696        // can we use the best empty?
697        if (bestEmpty != null) {
698            // yes
699            log.trace("   use bestEmpty");
700            bestEmpty.consumer = nodeID;
701            bestEmpty.consumerName = name;
702            handleTableUpdate(-1, -1);  // should be rows for bestEmpty, bestEmpty
703            return;
704        }
705
706        // can we just insert into the empty?
707        if (empty != null && sameNodeID == null) {
708            // yes
709            log.trace("   reuse empty");
710            empty.consumer = nodeID;
711            empty.consumerName = name;
712            handleTableUpdate(-1, -1);  // should be rows for empty, empty
713            return;
714        }
715
716        // is there a sameNodeID to insert into?
717        if (sameNodeID != null) {
718            // yes
719            log.trace("   switch to sameID");
720            var fromSaveNodeID = sameNodeID.consumer;
721            var fromSaveNodeIDName = sameNodeID.consumerName;
722            sameNodeID.consumer = nodeID;
723            sameNodeID.consumerName = name;
724            // now leave behind old cell to make new one
725            nodeID = fromSaveNodeID;
726            name = fromSaveNodeIDName;
727        }
728
729        // have to make a new one
730        log.trace("    make a new one");
731        var memo = new TripleMemo(
732                        eventID,
733                        rangeSuffix,
734                        null,
735                        "",
736                        nodeID,
737                        name
738                    );
739        memos.add(memo);
740        handleTableUpdate(memos.size()-1, memos.size()-1);
741     }
742
743    // This causes the display to jump around as it tried to keep
744    // the selected cell visible.
745    // TODO: A better approach might be to change
746    // the cell background color via a custom cell renderer
747    void highlightProducer(EventID eventID, NodeID nodeID) {
748        if (!popcornModeActive) return;
749        log.trace("highlightProducer {} {}", eventID, nodeID);
750        for (int i = 0; i < memos.size(); i++) {
751            var memo = memos.get(i);
752            if (eventID.equals(memo.eventID)  && memo.rangeSuffix.equals("") && nodeID.equals(memo.producer)) {
753                try {
754                    var viewRow = sorter.convertRowIndexToView(i);
755                    log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow);
756                    if (viewRow >= 0) {
757                        table.changeSelection(viewRow, COL_PRODUCER_NODE, false, false);
758                    }
759                } catch (ArrayIndexOutOfBoundsException e) {
760                    // can happen on first encounter of an event before table is updated
761                    log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i);
762                }
763            }
764        }
765    }
766
767    // highlights (selects) all the eventID cells with a particular event,
768    // Most LAFs will move the first of these on-scroll-view.
769    void highlightEvent(EventID eventID) {
770        log.trace("highlightEvent {}", eventID);
771        table.clearSelection(); // clear existing selections
772        for (int i = 0; i < memos.size(); i++) {
773            var memo = memos.get(i);
774            if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") ) {
775                try {
776                    var viewRow = sorter.convertRowIndexToView(i);
777                    log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow);
778                    if (viewRow >= 0) {
779                        table.changeSelection(viewRow, COL_EVENTID, true, false);
780                    }
781                } catch (ArrayIndexOutOfBoundsException e) {
782                    // can happen on first encounter of an event before table is updated
783                    log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i);
784                }
785            }
786        }
787    }
788
789    boolean consumerPresent(EventID eventID) {
790        for (var memo : memos) {
791            if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) {
792                if (memo.consumer!=null) return true;
793            }
794        }
795        return false;
796    }
797
798    boolean producerPresent(EventID eventID) {
799        for (var memo : memos) {
800            if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) {
801                if (memo.producer!=null) return true;
802            }
803        }
804        return false;
805    }
806
807    public static class TripleMemo { // would like to use a record for this; public for testing
808        public final EventID eventID;
809        public final String  rangeSuffix;
810        // Event name is stored separately, see getValueAt()
811        public NodeID producer;
812        public String producerName;
813        public NodeID consumer;
814        public String consumerName;
815
816        TripleMemo(EventID eventID, String rangeSuffix, NodeID producer, String producerName,
817                    NodeID consumer, String consumerName) {
818            this.eventID = eventID;
819            this.rangeSuffix = rangeSuffix;
820            this.producer = producer;
821            this.producerName = producerName;
822            this.consumer = consumer;
823            this.consumerName = consumerName;
824        }
825    }
826
827    /**
828     * Actual write of the CSV file
829     */
830    void csvWriteOperation(File file) {
831        try (CSVPrinter str = new CSVPrinter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8), CSVFormat.DEFAULT)) {
832            str.printRecord("Event ID", "Event Name", "Producer Node", "Producer Node Name",
833                            "Consumer Node", "Consumer Node Name", "Paths");                
834            for (int i = 0; i < getRowCount(); i++) {
835
836                str.print(getValueAt(i, COL_EVENTID));
837                str.print(getValueAt(i, COL_EVENTNAME));
838                str.print(getValueAt(i, COL_PRODUCER_NODE));
839                str.print(getValueAt(i, COL_PRODUCER_NAME));
840                str.print(getValueAt(i, COL_CONSUMER_NODE));
841                str.print(getValueAt(i, COL_CONSUMER_NAME));
842
843                String[] contexts = getValueAt(i, COL_CONTEXT_INFO).toString().split("\n"); // multi-line cell
844                for (String context : contexts) {
845                    str.print(context);
846                }
847                
848                str.println();
849            }
850            str.flush();
851        } catch (IOException ex) {
852            log.error("Error writing file", ex);
853        }
854    }
855
856    void csvReadOperation(File file) {
857        try (Reader in = new FileReader(file)) {
858            boolean first = true;
859            Iterable<CSVRecord> records = CSVFormat.RFC4180.parse(in);
860            
861            for (CSVRecord record : records) {
862                // skip the first record, as it's the column titles
863                if (first) {
864                    first = false;
865                    continue;
866                }
867                
868                String eventIDname = record.get(0);
869                 // Is the 1st column really an event ID
870                EventID eid;
871                try {
872                    eid = new EventID(eventIDname);
873                } catch (IllegalArgumentException e1) {
874                    // really shouldn't happen, as table manages column contents
875                    log.warn("Column 0 doesn't contain an EventID name: {}", eventIDname);
876                    continue;
877                }
878                // here we have a valid EventID, assign the name if currently blank
879                if (! nameStore.hasEventName(eid)) {
880                    String eventName = record.get(1);
881                    nameStore.addMatch(eid, eventName);
882                }         
883            }
884            log.debug("File reading complete");
885            // cause the table to update
886            fireTableDataChanged();
887            
888        } catch (IOException ex) {
889            log.error("Error reading file", ex);
890        }
891    }
892
893    public void loadModelData() {
894        log.debug("reading Event Table model data");
895        new EventTableDataModelXml(this).load();  // NOI18N
896        log.debug("...done reading Event Table model data");
897    }
898
899    private Runnable shutDownTask = null;
900
901    public void dispose() {
902        // this does _not_ deregister the shutdown task, 
903        // as that needs to happen at end of run to write
904        // any changes after this is closed.
905        //
906        // Instead, we just do a write here
907        log.debug("Start writing node name details...");
908        try {
909            writeNodeNameDetails();
910        } catch (java.io.IOException ioe) {
911            log.error("Exception writing event table data in dispose", ioe);
912        }
913        
914        // drop the external listeners
915        stdEventTable.removePropertyChangeListener(eventInfoListener);
916        
917    }
918    
919    
920    protected void initShutdownTask(){
921        // Create shutdown task to save
922        log.debug("Register ShutDown task");
923        if (this.shutDownTask == null) {
924            this.shutDownTask = () -> {
925                // Save event name details prior to exit, if necessary
926                log.debug("Start writing event table data...");
927                try {
928                    writeNodeNameDetails();
929                } catch (java.io.IOException ioe) {
930                    log.error("Exception writing event table data", ioe);
931                }
932            };
933            InstanceManager.getDefault(ShutDownManager.class).register(this.shutDownTask);
934        }
935    }
936
937    /**
938     * De-register the Shutdown task.
939     */
940    public void deregisterShutdownTask(){
941        log.debug("Deregister ShutDown task");
942        if ( shutDownTask != null ) {
943            InstanceManager.getDefault(ShutDownManager.class).deregister(shutDownTask);
944        }
945    }
946
947    public void writeNodeNameDetails() throws java.io.IOException {
948        log.debug("storing node name map");
949        new EventTableDataModelXml(this).store();  // NOI18N
950        log.debug("...done writing event name details");
951    }
952
953    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EventTableDataModel.class);
954}