001package jmri.jmrit.roster.swing;
002
003import com.fasterxml.jackson.databind.util.StdDateFormat;
004
005import java.beans.PropertyChangeEvent;
006import java.beans.PropertyChangeListener;
007import java.text.ParseException;
008import java.util.*;
009
010import javax.annotation.CheckForNull;
011import javax.swing.Icon;
012import javax.swing.ImageIcon;
013import javax.swing.JLabel;
014import javax.swing.JTable;
015import javax.swing.RowSorter;
016import javax.swing.table.DefaultTableModel;
017
018import jmri.BooleanPermission;
019import jmri.InstanceManager;
020import jmri.PermissionManager;
021import jmri.PermissionsSystemAdmin;
022import jmri.jmrit.decoderdefn.DecoderIndexFile;
023import jmri.jmrit.roster.Roster;
024import jmri.jmrit.roster.RosterEntry;
025import jmri.jmrit.roster.RosterIconFactory;
026import jmri.jmrit.roster.rostergroup.RosterGroup;
027import jmri.jmrit.roster.rostergroup.RosterGroupSelector;
028import jmri.util.swing.ResizableRowDataModel;
029import jmri.util.gui.GuiLafPreferencesManager;
030
031/**
032 * Table data model for display of Roster variable values.
033 * <p>
034 * Any desired ordering, etc, is handled outside this class.
035 * <p>
036 * The initial implementation doesn't automatically update when roster entries
037 * change, doesn't allow updating of the entries, and only shows some of the
038 * fields. But it's a start....
039 *
040 * @author Bob Jacobsen Copyright (C) 2009, 2010
041 * @since 2.7.5
042 */
043public class RosterTableModel extends DefaultTableModel implements PropertyChangeListener, ResizableRowDataModel {
044
045    public static final int IDCOL       = 0;
046    static final int ADDRESSCOL         = 1;
047    static final int ICONCOL            = 2;
048    static final int DECODERMFGCOL      = 3;
049    static final int DECODERFAMILYCOL   = 4;
050    static final int DECODERMODELCOL    = 5;
051    static final int ROADNAMECOL        = 6;
052    static final int ROADNUMBERCOL      = 7;
053    static final int MFGCOL             = 8;
054    static final int MODELCOL           = 9;
055    static final int OWNERCOL           = 10;
056    static final int DATEUPDATECOL      = 11;
057    public static final int PROTOCOL    = 12;
058    static final int COMMENT            = 13;
059    public static final int NUMCOL = COMMENT + 1;
060    private String rosterGroup = null;
061    boolean editable = false;
062    
063    static final PermissionManager permissionManager = InstanceManager.getDefault(PermissionManager.class);
064    
065    public RosterTableModel() {
066        this(false);
067    }
068
069    public RosterTableModel(boolean editable) {
070        this.editable = editable;
071        Roster.getDefault().addPropertyChangeListener(RosterTableModel.this);
072        setRosterGroup(null); // add prop change listeners to roster entries
073    }
074
075    /**
076     * Create a table model for a Roster group.
077     *
078     * @param group the roster group to show; if null, behaves the same as
079     *              {@link #RosterTableModel()}
080     */
081    public RosterTableModel(@CheckForNull RosterGroup group) {
082        this(false);
083        if (group != null) {
084            this.setRosterGroup(group.getName());
085        }
086    }
087
088    JTable associatedTable;
089    public void setAssociatedTable(JTable associatedTable) {
090        this.associatedTable = associatedTable;
091    }
092    
093    RowSorter<RosterTableModel> associatedSorter;
094    public void setAssociatedSorter(RowSorter<RosterTableModel> associatedSorter) {
095        this.associatedSorter = associatedSorter;
096    }
097    
098    
099    @Override
100    public void propertyChange(PropertyChangeEvent e) {
101        if (e.getPropertyName().equals(Roster.ADD)) {
102            setRosterGroup(getRosterGroup()); // add prop change listener to new entry
103            fireTableDataChanged();
104        } else if (e.getPropertyName().equals(Roster.REMOVE)) {
105            fireTableDataChanged();
106        } else if (e.getPropertyName().equals(Roster.SAVED)) {
107            //TODO This really needs to do something like find the index of the roster entry here
108            if (e.getSource() instanceof RosterEntry) {
109                int row = Roster.getDefault().getGroupIndex(rosterGroup, (RosterEntry) e.getSource());
110                fireTableRowsUpdated(row, row);
111            } else {
112                fireTableDataChanged();
113            }
114        } else if (e.getPropertyName().equals(RosterGroupSelector.SELECTED_ROSTER_GROUP)) {
115            setRosterGroup((e.getNewValue() != null) ? e.getNewValue().toString() : null);
116        } else if (e.getPropertyName().startsWith("attribute") && e.getSource() instanceof RosterEntry) { // NOI18N
117            int row = Roster.getDefault().getGroupIndex(rosterGroup, (RosterEntry) e.getSource());
118            fireTableRowsUpdated(row, row);
119        } else if (e.getPropertyName().equals(Roster.ROSTER_GROUP_ADDED) && e.getNewValue().equals(rosterGroup)) {
120            fireTableDataChanged();
121        }
122    }
123
124    @Override
125    public int getRowCount() {
126        return Roster.getDefault().numGroupEntries(rosterGroup);
127    }
128
129    @Override
130    public int getColumnCount() {
131        return NUMCOL + getModelAttributeKeyColumnNames().length;
132    }
133
134    @Override
135    public String getColumnName(int col) {
136        switch (col) {
137            case IDCOL:
138                return Bundle.getMessage("FieldID");
139            case ADDRESSCOL:
140                return Bundle.getMessage("FieldDCCAddress");
141            case DECODERMFGCOL:
142                return Bundle.getMessage("FieldDecoderMfg");
143            case DECODERFAMILYCOL:
144                return Bundle.getMessage("FieldDecoderFamily");
145            case DECODERMODELCOL:
146                return Bundle.getMessage("FieldDecoderModel");
147            case MODELCOL:
148                return Bundle.getMessage("FieldModel");
149            case ROADNAMECOL:
150                return Bundle.getMessage("FieldRoadName");
151            case ROADNUMBERCOL:
152                return Bundle.getMessage("FieldRoadNumber");
153            case MFGCOL:
154                return Bundle.getMessage("FieldManufacturer");
155            case ICONCOL:
156                return Bundle.getMessage("FieldIcon");
157            case OWNERCOL:
158                return Bundle.getMessage("FieldOwner");
159            case DATEUPDATECOL:
160                return Bundle.getMessage("FieldDateUpdated");
161            case PROTOCOL:
162                return Bundle.getMessage("FieldProtocol");
163            case COMMENT:
164                return Bundle.getMessage("FieldComment");
165            default:
166                return getColumnNameAttribute(col);
167        }
168    }
169
170    private String getColumnNameAttribute(int col) {
171        if ( col < getColumnCount() ) {
172            String attributeKey = getAttributeKey(col);
173            try {
174                return Bundle.getMessage(attributeKey);
175            } catch (java.util.MissingResourceException ex){}
176
177            String[] r = attributeKey.split("(?=\\p{Lu})"); // NOI18N
178            StringBuilder sb = new StringBuilder();
179            sb.append(r[0].trim());
180            for (int j = 1; j < r.length; j++) {
181                sb.append(" ");
182                sb.append(r[j].trim());
183            }
184            return sb.toString();
185        }
186        return "<UNKNOWN>"; // NOI18N
187    }
188
189    @Override
190    public Class<?> getColumnClass(int col) {
191        switch (col) {
192            case ADDRESSCOL:
193                return Integer.class;
194            case ICONCOL:
195                return ImageIcon.class;
196            case DATEUPDATECOL:
197                return Date.class;
198            default:
199                return getColumnClassAttribute(col);
200        }
201    }
202
203    private Class<?> getColumnClassAttribute(int col){
204        if (RosterEntry.ATTRIBUTE_LAST_OPERATED.equals( getAttributeKey(col))) {
205            return Date.class;
206        }
207        if (RosterEntry.ATTRIBUTE_OPERATING_DURATION.equals( getAttributeKey(col))) {
208            return Integer.class;
209        }
210        return String.class;
211    }
212
213    /**
214     * {@inheritDoc}
215     * <p>
216     * Note that the table can be set to be non-editable when constructed, in
217     * which case this always returns false.
218     *
219     * @return true if cell is editable in roster entry model and table allows
220     *         editing
221     */
222    @Override
223    public boolean isCellEditable(int row, int col) {
224        if (col == ADDRESSCOL) {
225            return false;
226        }
227        if (col == PROTOCOL) {
228            return false;
229        }
230        if (col == DECODERMFGCOL) {
231            return false;
232        }
233        if (col == DECODERFAMILYCOL) {
234            return false;
235        }
236        if (col == DECODERMODELCOL) {
237            return false;
238        }
239        if (col == ICONCOL) {
240            return false;
241        }
242        if (col == DATEUPDATECOL) {
243            return false;
244        }
245        if (editable) {
246            // permission to edit optional columns?
247            if ( col >= NUMCOL && col < getColumnCount() ) {
248                if (! permissionManager.hasAtLeastPermission(PermissionsSystemAdmin.PERMISSION_EDIT_PREFERENCES,
249                                                    BooleanPermission.BooleanValue.TRUE)) {
250                    return false;
251                }
252            }
253            RosterEntry re = Roster.getDefault().getGroupEntry(rosterGroup, row);
254            if (re != null) {
255                return (!re.isOpen());
256            }
257        }
258        return editable;
259    }
260
261    RosterIconFactory iconFactory = null;
262
263    ImageIcon getIcon(RosterEntry re) {
264        // defer image handling to RosterIconFactory
265        if (iconFactory == null) {
266            iconFactory = new RosterIconFactory(Math.max(19, new JLabel(getColumnName(0)).getPreferredSize().height));
267        }
268        return iconFactory.getIcon(re);
269    }
270
271    /**
272     * {@inheritDoc}
273     *
274     * Provides an empty string for a column if the model returns null for that
275     * value.
276     */
277    @Override
278    public Object getValueAt(int row, int col) {
279        // get roster entry for row
280        RosterEntry re = Roster.getDefault().getGroupEntry(rosterGroup, row);
281        if (re == null) {
282            log.debug("roster entry is null!");
283            return null;
284        }
285        switch (col) {
286            case IDCOL:
287                return re.getId();
288            case ADDRESSCOL:
289                return re.getDccLocoAddress().getNumber();
290            case DECODERMFGCOL:
291                var index = InstanceManager.getDefault(DecoderIndexFile.class);
292                var matches = index.matchingDecoderList(
293                        null, re.getDecoderFamily(),
294                        null, null, null,
295                        re.getDecoderModel()
296                        );
297                if (matches.size() == 0) return "";
298                return matches.get(0).getMfg();
299            case DECODERFAMILYCOL:
300                return re.getDecoderFamily();
301            case DECODERMODELCOL:
302                return re.getDecoderModel();
303            case MODELCOL:
304                return re.getModel();
305            case ROADNAMECOL:
306                return re.getRoadName();
307            case ROADNUMBERCOL:
308                return re.getRoadNumber();
309            case MFGCOL:
310                return re.getMfg();
311            case ICONCOL:
312                return getIcon(re);
313            case OWNERCOL:
314                return re.getOwner();
315            case DATEUPDATECOL:
316                // will not display last update if not parsable as date
317                return re.getDateModified();
318            case PROTOCOL:
319                return re.getProtocolAsString();
320            case COMMENT:
321                // have to set height for extra lines
322                resizeRowToText(row, findMaxLines(row, re));
323                return re.getComment();
324            default:
325                break;
326        }
327        resizeRowToText(row, findMaxLines(row, re));
328        return getValueAtAttribute(re, col);
329    }
330
331    int findMaxLines(int row, RosterEntry re) {
332        int lines = countLinesIn(re.getComment());
333
334        String[] auxAttributeNames = getModelAttributeKeyColumnNames();
335        for (String attributeKey : auxAttributeNames) {
336            String value = re.getAttribute(attributeKey);
337            if (value != null) {
338                int count = countLinesIn(value);
339                lines = Math.max(lines, count);
340            }
341        }
342        return lines;
343    }
344    
345    int countLinesIn(String text) {
346        String[] sections = text.split("\n");
347        int lines = sections.length;
348        return lines;
349    }
350    
351    @Override
352    public void resizeRowToText(int modelRow, int heightInLines) {
353        if (associatedSorter == null || associatedTable == null ) {
354            return; // because not initialized, can't act - useful for tests
355        }
356        
357        if (heightInLines < 1) heightInLines = 1;       // always show at least one line
358        
359        var viewRow = associatedSorter.convertRowIndexToView(modelRow);
360        int height = heightInLines * (InstanceManager.getDefault(GuiLafPreferencesManager.class).getFontSize() + 4); // same line height as in RosterTable
361        if (height != associatedTable.getRowHeight(viewRow)) {
362            associatedTable.setRowHeight(viewRow, height);
363        }
364    }
365    
366    private Object getValueAtAttribute(RosterEntry re, int col){
367        String attributeKey = getAttributeKey(col);
368        String value = re.getAttribute(attributeKey); // NOI18N
369        if (RosterEntry.ATTRIBUTE_LAST_OPERATED.equals( attributeKey)) {
370            if (value == null){
371                return null;
372            }
373            try {
374                return new StdDateFormat().parse(value);
375            } catch (ParseException ex){
376                return null;
377            }
378        }
379        if ( RosterEntry.ATTRIBUTE_OPERATING_DURATION.equals( attributeKey) ) {
380            try {
381                return Integer.valueOf(value);
382            }
383            catch (NumberFormatException e) {
384                log.debug("could not format duration ( String integer of total seconds ) in {}", value, e);
385            }
386            return 0;
387        }
388        return (value == null ? "" : value);
389    }
390
391    @Override
392    public void setValueAt(Object value, int row, int col) {
393        // get roster entry for row
394        RosterEntry re = Roster.getDefault().getGroupEntry(rosterGroup, row);
395        if (re == null) {
396            log.warn("roster entry is null!");
397            return;
398        }
399        if (re.isOpen()) {
400            log.warn("Entry is already open");
401            return;
402        }
403        if (Objects.equals(value, getValueAt(row, col))) {
404            return;
405        }
406        String valueToSet = (String) value;
407        switch (col) {
408            case IDCOL:
409                re.setId(valueToSet);
410                break;
411            case ROADNAMECOL:
412                re.setRoadName(valueToSet);
413                break;
414            case ROADNUMBERCOL:
415                re.setRoadNumber(valueToSet);
416                break;
417            case MFGCOL:
418                re.setMfg(valueToSet);
419                break;
420            case MODELCOL:
421                re.setModel(valueToSet);
422                break;
423            case OWNERCOL:
424                re.setOwner(valueToSet);
425                break;
426            case COMMENT:
427                re.setComment(valueToSet);
428                break;
429            default:
430                // permission to edit optional columns?
431                if (! permissionManager.ensureAtLeastPermission(PermissionsSystemAdmin.PERMISSION_EDIT_PREFERENCES,
432                                                        BooleanPermission.BooleanValue.TRUE)) {
433                    return;
434                }
435                
436                setValueAtAttribute(valueToSet, re, col);
437                break;
438        }
439        // need to mark as updated
440        re.changeDateUpdated();
441        re.updateFile();
442    }
443
444    private void setValueAtAttribute(String valueToSet, RosterEntry re, int col) {
445        String attributeKey = getAttributeKey(col);
446        if ((valueToSet == null) || valueToSet.isEmpty()) {
447            re.deleteAttribute(attributeKey);
448        } else {
449            re.putAttribute(attributeKey, valueToSet);
450        }
451    }
452
453    public int getPreferredWidth(int column) {
454        int retval = 20; // always take some width
455        retval = Math.max(retval, new JLabel(getColumnName(column))
456            .getPreferredSize().width + 15);  // leave room for sorter arrow
457        for (int row = 0; row < getRowCount(); row++) {
458            if (getColumnClass(column).equals(String.class)) {
459                retval = Math.max(retval, new JLabel(getValueAt(row, column).toString()).getPreferredSize().width);
460            } else if (getColumnClass(column).equals(Integer.class)) {
461                retval = Math.max(retval, new JLabel(getValueAt(row, column).toString()).getPreferredSize().width);
462            } else if (getColumnClass(column).equals(ImageIcon.class)) {
463                retval = Math.max(retval, new JLabel((Icon) getValueAt(row, column)).getPreferredSize().width);
464            }
465        }
466        return retval + 5;
467    }
468
469    public final void setRosterGroup(String rosterGroup) {
470        Roster.getDefault().getEntriesInGroup(this.rosterGroup).forEach( re ->
471            re.removePropertyChangeListener(this));
472        this.rosterGroup = rosterGroup;
473        Roster.getDefault().getEntriesInGroup(rosterGroup).forEach( re ->
474            re.addPropertyChangeListener(this));
475        fireTableDataChanged();
476    }
477
478    public final String getRosterGroup() {
479        return this.rosterGroup;
480    }
481
482    // access via method to ensure not null
483    private String[] attributeKeys = null; 
484
485    private String[] getModelAttributeKeyColumnNames() {
486        if ( attributeKeys == null ) {
487            Set<String> result = new TreeSet<>();
488            for (String s : Roster.getDefault().getAllAttributeKeys()) {
489                if ( !s.contains("RosterGroup")
490                    && !s.toLowerCase().startsWith("sys")
491                    && !s.toUpperCase().startsWith("VSD")) { // NOI18N
492                    result.add(s);
493                }
494            }
495            attributeKeys = result.toArray(String[]::new);
496        }
497        return attributeKeys;
498    }
499
500    private String getAttributeKey(int col) {
501        if ( col >= NUMCOL && col < getColumnCount() ) {
502            return getModelAttributeKeyColumnNames()[col - NUMCOL ];
503        }
504        return "";
505    }
506
507    // drop listeners
508    public void dispose() {
509        Roster.getDefault().removePropertyChangeListener(this);
510        Roster.getDefault().getEntriesInGroup(this.rosterGroup).forEach( re ->
511            re.removePropertyChangeListener(this) );
512    }
513
514    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RosterTableModel.class);
515
516}