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