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