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 = countLinesIn(re.getComment());
330
331        String[] auxAttributeNames = getModelAttributeKeyColumnNames();
332        for (String attributeKey : auxAttributeNames) {
333            String value = re.getAttribute(attributeKey);
334            if (value != null) {
335                int count = countLinesIn(value);
336                lines = Math.max(lines, count);
337            }
338        }
339        return lines;
340    }
341
342    int countLinesIn(String text) {
343        String[] sections = text.split("\n");
344        int lines = sections.length;
345        return lines;
346    }
347
348    @Override
349    public void resizeRowToText(int modelRow, int heightInLines) {
350        if (associatedSorter == null || associatedTable == null ) {
351            return; // because not initialized, can't act - useful for tests
352        }
353
354        if (heightInLines < 1) heightInLines = 1;       // always show at least one line
355
356        var viewRow = associatedSorter.convertRowIndexToView(modelRow);
357        int height = heightInLines * (InstanceManager.getDefault(GuiLafPreferencesManager.class).getFontSize() + 4); // same line height as in RosterTable
358        if (height != associatedTable.getRowHeight(viewRow)) {
359            associatedTable.setRowHeight(viewRow, height);
360        }
361    }
362
363    private Object getValueAtAttribute(RosterEntry re, int col){
364        String attributeKey = getAttributeKey(col);
365        String value = re.getAttribute(attributeKey); // NOI18N
366        if (RosterEntry.ATTRIBUTE_LAST_OPERATED.equals( attributeKey)) {
367            if (value == null){
368                return null;
369            }
370            try {
371                return new StdDateFormat().parse(value);
372            } catch (ParseException ex){
373                return null;
374            }
375        }
376        if ( RosterEntry.ATTRIBUTE_OPERATING_DURATION.equals( attributeKey) ) {
377            try {
378                return Integer.valueOf(value);
379            }
380            catch (NumberFormatException e) {
381                log.debug("could not format duration ( String integer of total seconds ) in {}", value, e);
382            }
383            return 0;
384        }
385        return (value == null ? "" : value);
386    }
387
388    @Override
389    public void setValueAt(Object value, int row, int col) {
390        // get roster entry for row
391        RosterEntry re = Roster.getDefault().getGroupEntry(rosterGroup, row);
392        if (re == null) {
393            log.warn("roster entry is null!");
394            return;
395        }
396        if (re.isOpen()) {
397            log.warn("Entry is already open");
398            return;
399        }
400        if (Objects.equals(value, getValueAt(row, col))) {
401            return;
402        }
403        String valueToSet = (String) value;
404        switch (col) {
405            case IDCOL:
406                re.setId(valueToSet);
407                break;
408            case ROADNAMECOL:
409                re.setRoadName(valueToSet);
410                break;
411            case ROADNUMBERCOL:
412                re.setRoadNumber(valueToSet);
413                break;
414            case MFGCOL:
415                re.setMfg(valueToSet);
416                break;
417            case MODELCOL:
418                re.setModel(valueToSet);
419                break;
420            case OWNERCOL:
421                re.setOwner(valueToSet);
422                break;
423            case COMMENT:
424                re.setComment(valueToSet);
425                break;
426            default:
427                // permission to edit optional columns?
428                if (! permissionManager.ensureAtLeastPermission(PermissionsProgrammer.PERMISSION_ROSTER_ADDED_COLUMNS,
429                                                        BooleanPermission.BooleanValue.TRUE)) {
430                    return;
431                }
432
433                setValueAtAttribute(valueToSet, re, col);
434                break;
435        }
436        // need to mark as updated
437        re.changeDateUpdated();
438        re.updateFile();
439    }
440
441    private void setValueAtAttribute(String valueToSet, RosterEntry re, int col) {
442        String attributeKey = getAttributeKey(col);
443        if ((valueToSet == null) || valueToSet.isEmpty()) {
444            re.deleteAttribute(attributeKey);
445        } else {
446            re.putAttribute(attributeKey, valueToSet);
447        }
448    }
449
450    public int getPreferredWidth(int column) {
451        int retval = 20; // always take some width
452        retval = Math.max(retval, new JLabel(getColumnName(column))
453            .getPreferredSize().width + 15);  // leave room for sorter arrow
454        for (int row = 0; row < getRowCount(); row++) {
455            if (getColumnClass(column).equals(String.class)) {
456                retval = Math.max(retval, new JLabel(getValueAt(row, column).toString()).getPreferredSize().width);
457            } else if (getColumnClass(column).equals(Integer.class)) {
458                retval = Math.max(retval, new JLabel(getValueAt(row, column).toString()).getPreferredSize().width);
459            } else if (getColumnClass(column).equals(ImageIcon.class)) {
460                retval = Math.max(retval, new JLabel((Icon) getValueAt(row, column)).getPreferredSize().width);
461            }
462        }
463        return retval + 5;
464    }
465
466    public final void setRosterGroup(String rosterGroup) {
467        Roster.getDefault().getEntriesInGroup(this.rosterGroup).forEach( re ->
468            re.removePropertyChangeListener(this));
469        this.rosterGroup = rosterGroup;
470        Roster.getDefault().getEntriesInGroup(rosterGroup).forEach( re ->
471            re.addPropertyChangeListener(this));
472        fireTableDataChanged();
473    }
474
475    public final String getRosterGroup() {
476        return this.rosterGroup;
477    }
478
479    // access via method to ensure not null
480    private String[] attributeKeys = null;
481
482    private String[] getModelAttributeKeyColumnNames() {
483        if ( attributeKeys == null ) {
484            Set<String> result = new TreeSet<>();
485            for (String s : Roster.getDefault().getAllAttributeKeys()) {
486                if ( !s.contains("RosterGroup")
487                    && !s.toLowerCase().startsWith("sys")
488                    && !s.toUpperCase().startsWith("VSD")) { // NOI18N
489                    result.add(s);
490                }
491            }
492            attributeKeys = result.toArray(String[]::new);
493        }
494        return attributeKeys;
495    }
496
497    private String getAttributeKey(int col) {
498        if ( col >= NUMCOL && col < getColumnCount() ) {
499            return getModelAttributeKeyColumnNames()[col - NUMCOL ];
500        }
501        return "";
502    }
503
504    // drop listeners
505    public void dispose() {
506        Roster.getDefault().removePropertyChangeListener(this);
507        Roster.getDefault().getEntriesInGroup(this.rosterGroup).forEach( re ->
508            re.removePropertyChangeListener(this) );
509    }
510
511    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RosterTableModel.class);
512
513}