1 /***************************************************************************
2  *   Copyright (C) 2004-2018 by Thomas Fischer <fischer@unix-ag.uni-kl.de> *
3  *                                                                         *
4  *   This program is free software; you can redistribute it and/or modify  *
5  *   it under the terms of the GNU General Public License as published by  *
6  *   the Free Software Foundation; either version 2 of the License, or     *
7  *   (at your option) any later version.                                   *
8  *                                                                         *
9  *   This program is distributed in the hope that it will be useful,       *
10  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
11  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
12  *   GNU General Public License for more details.                          *
13  *                                                                         *
14  *   You should have received a copy of the GNU General Public License     *
15  *   along with this program; if not, see <https://www.gnu.org/licenses/>. *
16  ***************************************************************************/
17 
18 #include "valuelistmodel.h"
19 
20 #include <typeinfo>
21 
22 #include <QApplication>
23 #include <QTextDocument>
24 #include <QAbstractTextDocumentLayout>
25 #include <QListView>
26 #include <QGridLayout>
27 #include <QStringListModel>
28 #include <QPainter>
29 #include <QFrame>
30 #include <QLayout>
31 #include <QHeaderView>
32 
33 #include <KComboBox>
34 #include <KLocalizedString>
35 #include <KSharedConfig>
36 #include <KConfigGroup>
37 #include <KColorScheme>
38 #include <KLineEdit>
39 
40 #include "fieldlineedit.h"
41 #include "bibtexfields.h"
42 #include "entry.h"
43 #include "preferences.h"
44 #include "models/filemodel.h"
45 #include "logging_gui.h"
46 
createEditor(QWidget * parent,const QStyleOptionViewItem & sovi,const QModelIndex & index) const47 QWidget *ValueListDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &sovi, const QModelIndex &index) const
48 {
49     if (index.column() == 0) {
50         const FieldDescription &fd = BibTeXFields::instance().find(m_fieldName);
51         FieldLineEdit *fieldLineEdit = new FieldLineEdit(fd.preferredTypeFlag, fd.typeFlags, false, parent);
52         fieldLineEdit->setAutoFillBackground(true);
53         return fieldLineEdit;
54     } else
55         return QStyledItemDelegate::createEditor(parent, sovi, index);
56 }
57 
setEditorData(QWidget * editor,const QModelIndex & index) const58 void ValueListDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
59 {
60     if (index.column() == 0) {
61         FieldLineEdit *fieldLineEdit = qobject_cast<FieldLineEdit *>(editor);
62         if (fieldLineEdit != nullptr)
63             fieldLineEdit->reset(index.model()->data(index, Qt::EditRole).value<Value>());
64     }
65 }
66 
setModelData(QWidget * editor,QAbstractItemModel * model,const QModelIndex & index) const67 void ValueListDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const
68 {
69     FieldLineEdit *fieldLineEdit = qobject_cast<FieldLineEdit *>(editor);
70     if (fieldLineEdit != nullptr) {
71         Value v;
72         fieldLineEdit->apply(v);
73         if (v.count() == 1) /// field should contain exactly one value item (no zero, not two or more)
74             model->setData(index, QVariant::fromValue(v));
75     }
76 }
77 
sizeHint(const QStyleOptionViewItem & option,const QModelIndex & index) const78 QSize ValueListDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
79 {
80     QSize size = QStyledItemDelegate::sizeHint(option, index);
81     size.setHeight(qMax(size.height(), option.fontMetrics.height() * 3 / 2));   // TODO calculate height better
82     return size;
83 }
84 
commitAndCloseEditor()85 void ValueListDelegate::commitAndCloseEditor()
86 {
87     KLineEdit *editor = qobject_cast<KLineEdit *>(sender());
88     emit commitData(editor);
89     emit closeEditor(editor);
90 }
91 
initStyleOption(QStyleOptionViewItem * option,const QModelIndex & index) const92 void ValueListDelegate::initStyleOption(QStyleOptionViewItem *option, const QModelIndex &index) const
93 {
94     QStyledItemDelegate::initStyleOption(option, index);
95     if (option->decorationPosition != QStyleOptionViewItem::Top) {
96         /// remove text from style (do not draw text)
97         option->text.clear();
98     }
99 
100 }
101 
paint(QPainter * painter,const QStyleOptionViewItem & _option,const QModelIndex & index) const102 void ValueListDelegate::paint(QPainter *painter, const QStyleOptionViewItem &_option, const QModelIndex &index) const
103 {
104     QStyleOptionViewItem option = _option;
105 
106     /// code heavily inspired by kdepimlibs-4.6.3/akonadi/collectionstatisticsdelegate.cpp
107 
108     /// save painter's state, restored before leaving this function
109     painter->save();
110 
111     /// first, paint the basic, but without the text. We remove the text
112     /// in initStyleOption(), which gets called by QStyledItemDelegate::paint().
113     QStyledItemDelegate::paint(painter, option, index);
114 
115     /// now, we retrieve the correct style option by calling intiStyleOption from
116     /// the superclass.
117     QStyledItemDelegate::initStyleOption(&option, index);
118     QString field = option.text;
119 
120     /// now calculate the rectangle for the text
121     QStyle *s = m_parent->style();
122     const QWidget *widget = option.widget;
123     const QRect textRect = s->subElementRect(QStyle::SE_ItemViewItemText, &option, widget);
124 
125     if (option.state & QStyle::State_Selected) {
126         /// selected lines are drawn with different color
127         painter->setPen(option.palette.highlightedText().color());
128     }
129 
130     /// count will be empty unless only one column is shown
131     const QString count = index.column() == 0 && index.model()->columnCount() == 1 ? QString(QStringLiteral(" (%1)")).arg(index.data(ValueListModel::CountRole).toInt()) : QString();
132 
133     /// squeeze the folder text if it is to big and calculate the rectangles
134     /// where the folder text and the unread count will be drawn to
135     QFontMetrics fm(painter->fontMetrics());
136     int countWidth = fm.width(count);
137     int fieldWidth = fm.width(field);
138     if (countWidth + fieldWidth > textRect.width()) {
139         /// text plus count is too wide for column, cut text and insert "..."
140         field = fm.elidedText(field, Qt::ElideRight, textRect.width() - countWidth - 8);
141         fieldWidth = textRect.width() - countWidth - 12;
142     }
143 
144     /// determine rects to draw field
145     int top = textRect.top() + (textRect.height() - fm.height()) / 2;
146     QRect fieldRect = textRect;
147     QRect countRect = textRect;
148     fieldRect.setTop(top);
149     fieldRect.setHeight(fm.height());
150 
151     if (m_parent->header()->visualIndex(index.column()) == 0) {
152         /// left-align text
153         fieldRect.setLeft(fieldRect.left() + 4); ///< hm, indent necessary?
154         fieldRect.setRight(fieldRect.left() + fieldWidth);
155     } else {
156         /// right-align text
157         fieldRect.setRight(fieldRect.right() - 4); ///< hm, indent necessary?
158         fieldRect.setLeft(fieldRect.right() - fieldWidth); ///< hm, indent necessary?
159     }
160 
161     /// draw field name
162     painter->drawText(fieldRect, Qt::AlignLeft, field);
163 
164     if (!count.isEmpty()) {
165         /// determine rects to draw count
166         countRect.setTop(top);
167         countRect.setHeight(fm.height());
168         countRect.setLeft(fieldRect.right());
169 
170         /// use bold font
171         QFont font = painter->font();
172         font.setBold(true);
173         painter->setFont(font);
174         /// determine color for count number
175         const QColor countColor = (option.state & QStyle::State_Selected) ? KColorScheme(QPalette::Active, KColorScheme::Selection).foreground(KColorScheme::LinkText).color() : KColorScheme(QPalette::Active, KColorScheme::View).foreground(KColorScheme::LinkText).color();
176         painter->setPen(countColor);
177 
178         /// draw count
179         painter->drawText(countRect, Qt::AlignLeft, count);
180     }
181 
182     /// restore painter's state
183     painter->restore();
184 }
185 
ValueListModel(const File * bibtexFile,const QString & fieldName,QObject * parent)186 ValueListModel::ValueListModel(const File *bibtexFile, const QString &fieldName, QObject *parent)
187         : QAbstractTableModel(parent), file(bibtexFile), fName(fieldName.toLower()), showCountColumn(true), sortBy(SortByText)
188 {
189     readConfiguration();
190     updateValues();
191     NotificationHub::registerNotificationListener(this, NotificationHub::EventConfigurationChanged);
192 }
193 
rowCount(const QModelIndex & parent) const194 int ValueListModel::rowCount(const QModelIndex &parent) const
195 {
196     return parent == QModelIndex() ? values.count() : 0;
197 }
198 
columnCount(const QModelIndex & parent) const199 int ValueListModel::columnCount(const QModelIndex &parent) const
200 {
201     return parent == QModelIndex() ? (showCountColumn ? 2 : 1) : 0;
202 }
203 
data(const QModelIndex & index,int role) const204 QVariant ValueListModel::data(const QModelIndex &index, int role) const
205 {
206     if (index.row() >= values.count() || index.column() >= 2)
207         return QVariant();
208     if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
209         if (index.column() == 0) {
210             if (fName == Entry::ftColor) {
211                 QString text = values[index.row()].text;
212                 if (text.isEmpty()) return QVariant();
213                 QString colorText = colorToLabel[text];
214                 if (colorText.isEmpty()) return QVariant(text);
215                 return QVariant(colorText);
216             } else
217                 return QVariant(values[index.row()].text);
218         } else
219             return QVariant(values[index.row()].count);
220     } else if (role == SortRole) {
221         static const QRegularExpression ignoredInSorting(QStringLiteral("[{}\\\\]+"));
222 
223         QString buffer = values[index.row()].sortBy.isEmpty() ? values[index.row()].text : values[index.row()].sortBy;
224         buffer = buffer.remove(ignoredInSorting).toLower();
225 
226         if ((showCountColumn && index.column() == 1) || (!showCountColumn && sortBy == SortByCount)) {
227             /// Sort by string consisting of a zero-padded count and the lower-case text,
228             /// for example "0000000051keyword"
229             /// Used if (a) two columns are shown (showCountColumn is true) and column 1
230             /// (the count column) is to be sorted or (b) if only one column is shown
231             /// (showCountColumn is false) and this single column is to be sorted by count.
232             return QString(QStringLiteral("%1%2")).arg(values[index.row()].count, 10, 10, QLatin1Char('0')).arg(buffer);
233         } else {
234             /// Otherwise use lower-case text for sorting
235             return QVariant(buffer);
236         }
237     } else if (role == SearchTextRole) {
238         return QVariant(values[index.row()].text);
239     } else if (role == Qt::EditRole)
240         return QVariant::fromValue(values[index.row()].value);
241     else if (role == CountRole)
242         return QVariant(values[index.row()].count);
243     else
244         return QVariant();
245 }
246 
setData(const QModelIndex & index,const QVariant & value,int role)247 bool ValueListModel::setData(const QModelIndex &index, const QVariant &value, int role)
248 {
249     Q_ASSERT_X(file != nullptr, "ValueListModel::setData", "You cannot set data if there is no BibTeX file associated with this value list.");
250 
251     /// Continue only if in edit role and first column is to be changed
252     if (role == Qt::EditRole && index.column() == 0) {
253         /// Fetch the string as it was shown before the editing started
254         QString origText = data(index, Qt::DisplayRole).toString();
255         /// Special treatment for colors
256         if (fName == Entry::ftColor) {
257             /// for colors, convert color (RGB) to the associated label
258             QString color = colorToLabel.key(origText);
259             if (!color.isEmpty()) origText = color;
260         }
261 
262         /// Retrieve the Value object containing the user-entered data
263         Value newValue = value.value<Value>(); /// nice variable names ... ;-)
264         if (newValue.isEmpty()) {
265             qCWarning(LOG_KBIBTEX_GUI) << "Cannot replace with empty value";
266             return false;
267         }
268 
269         /// Fetch the string representing the new, user-entered value
270         const QString newText = PlainTextValue::text(newValue);
271         if (newText == origText) {
272             qCWarning(LOG_KBIBTEX_GUI) << "Skipping to replace value with itself";
273             return false;
274         }
275 
276         bool success = searchAndReplaceValueInEntries(index, newValue) && searchAndReplaceValueInModel(index, newValue);
277         return success;
278     }
279     return false;
280 }
281 
flags(const QModelIndex & index) const282 Qt::ItemFlags ValueListModel::flags(const QModelIndex &index) const
283 {
284     Qt::ItemFlags result = QAbstractTableModel::flags(index);
285     /// make first column editable
286     if (index.column() == 0)
287         result |= Qt::ItemIsEditable;
288     return result;
289 }
290 
headerData(int section,Qt::Orientation orientation,int role) const291 QVariant ValueListModel::headerData(int section, Qt::Orientation orientation, int role) const
292 {
293     if (section >= 2 || orientation != Qt::Horizontal || role != Qt::DisplayRole)
294         return QVariant();
295     else if ((section == 0 && columnCount() == 2) || (columnCount() == 1 && sortBy == SortByText))
296         return QVariant(i18n("Value"));
297     else
298         return QVariant(i18n("Count"));
299 }
300 
removeValue(const QModelIndex & index)301 void ValueListModel::removeValue(const QModelIndex &index)
302 {
303     removeValueFromEntries(index);
304     removeValueFromModel(index);
305 }
306 
setShowCountColumn(bool showCountColumn)307 void ValueListModel::setShowCountColumn(bool showCountColumn)
308 {
309     beginResetModel();
310     this->showCountColumn = showCountColumn;
311     endResetModel();
312 }
313 
setSortBy(SortBy sortBy)314 void ValueListModel::setSortBy(SortBy sortBy)
315 {
316     beginResetModel();
317     this->sortBy = sortBy;
318     endResetModel();
319 }
320 
notificationEvent(int eventId)321 void ValueListModel::notificationEvent(int eventId)
322 {
323     if (eventId == NotificationHub::EventConfigurationChanged) {
324         beginResetModel();
325         readConfiguration();
326         endResetModel();
327     }
328 }
329 
readConfiguration()330 void ValueListModel::readConfiguration()
331 {
332     /// load mapping from color value to label
333     KSharedConfigPtr config(KSharedConfig::openConfig(QStringLiteral("kbibtexrc")));
334     KConfigGroup configGroup(config, Preferences::groupColor);
335     QStringList colorCodes = configGroup.readEntry(Preferences::keyColorCodes, Preferences::defaultColorCodes);
336     QStringList colorLabels = configGroup.readEntry(Preferences::keyColorLabels, Preferences::defaultColorLabels);
337     colorToLabel.clear();
338     for (QStringList::ConstIterator itc = colorCodes.constBegin(), itl = colorLabels.constBegin(); itc != colorCodes.constEnd() && itl != colorLabels.constEnd(); ++itc, ++itl) {
339         colorToLabel.insert(*itc, i18n((*itl).toUtf8().constData()));
340     }
341 }
342 
updateValues()343 void ValueListModel::updateValues()
344 {
345     values.clear();
346     if (file == nullptr) return;
347 
348     for (const auto &element : const_cast<const File &>(*file)) {
349         QSharedPointer<const Entry> entry = element.dynamicCast<const Entry>();
350         if (!entry.isNull()) {
351             for (Entry::ConstIterator eit = entry->constBegin(); eit != entry->constEnd(); ++eit) {
352                 QString key = eit.key().toLower();
353                 if (key == fName) {
354                     insertValue(eit.value());
355                     break;
356                 }
357                 if (eit.value().isEmpty())
358                     qCWarning(LOG_KBIBTEX_GUI) << "value for key" << key << "in entry" << entry->id() << "is empty";
359             }
360         }
361     }
362 }
363 
insertValue(const Value & value)364 void ValueListModel::insertValue(const Value &value)
365 {
366     for (const QSharedPointer<ValueItem> &item : value) {
367         const QString text = PlainTextValue::text(*item);
368         if (text.isEmpty()) continue; ///< skip empty values
369 
370         int index = indexOf(text);
371         if (index < 0) {
372             /// previously unknown text
373             ValueLine newValueLine;
374             newValueLine.text = text;
375             newValueLine.count = 1;
376             newValueLine.value.append(item);
377 
378             /// memorize sorting criterium:
379             /// * for persons, use last name first
380             /// * in any case, use lower case
381             const QSharedPointer<Person> person = item.dynamicCast<Person>();
382             newValueLine.sortBy = person.isNull() ? text.toLower() : person->lastName().toLower() + QStringLiteral(" ") + person->firstName().toLower();
383 
384             values << newValueLine;
385         } else {
386             ++values[index].count;
387         }
388     }
389 }
390 
indexOf(const QString & text)391 int ValueListModel::indexOf(const QString &text)
392 {
393     QString color;
394     QString cmpText = text;
395     if (fName == Entry::ftColor && !(color = colorToLabel.key(text, QString())).isEmpty())
396         cmpText = color;
397     if (cmpText.isEmpty())
398         qCWarning(LOG_KBIBTEX_GUI) << "Should never happen";
399 
400     int i = 0;
401     /// this is really slow for large data sets: O(n^2)
402     /// maybe use a hash table instead?
403     for (const ValueLine &valueLine : const_cast<const ValueLineList &>(values)) {
404         if (valueLine.text == cmpText)
405             return i;
406         ++i;
407     }
408     return -1;
409 }
410 
searchAndReplaceValueInEntries(const QModelIndex & index,const Value & newValue)411 bool ValueListModel::searchAndReplaceValueInEntries(const QModelIndex &index, const Value &newValue)
412 {
413     /// Fetch the string representing the new, user-entered value
414     const QString newText = PlainTextValue::text(newValue);
415 
416     if (newText.isEmpty())
417         return false;
418 
419     /// Fetch the string as it was shown before the editing started
420     QString origText = data(index, Qt::DisplayRole).toString();
421     /// Special treatment for colors
422     if (fName == Entry::ftColor) {
423         /// for colors, convert color (RGB) to the associated label
424         QString color = colorToLabel.key(origText);
425         if (!color.isEmpty()) origText = color;
426     }
427 
428     /// Go through all elements in the current file
429     for (const QSharedPointer<Element> &element : const_cast<const File &>(*file)) {
430         QSharedPointer<Entry> entry = element.dynamicCast<Entry>();
431         /// Process only Entry objects
432         if (!entry.isNull()) {
433             /// Go through every key-value pair in entry (author, title, ...)
434             for (Entry::Iterator eit = entry->begin(); eit != entry->end(); ++eit) {
435                 /// Fetch key-value pair's key
436                 const QString key = eit.key().toLower();
437                 /// Process only key-value pairs that are filtered for (e.g. only keywords)
438                 if (key == fName) {
439                     eit.value().replace(origText, newValue.first());
440                     break;
441                 }
442             }
443         }
444     }
445 
446     return true;
447 }
448 
searchAndReplaceValueInModel(const QModelIndex & index,const Value & newValue)449 bool ValueListModel::searchAndReplaceValueInModel(const QModelIndex &index, const Value &newValue)
450 {
451     /// Fetch the string representing the new, user-entered value
452     const QString newText = PlainTextValue::text(newValue);
453     if (newText.isEmpty())
454         return false;
455 
456     const int row = index.row();
457 
458     /// Test if user-entered text exists already in model's data
459     /// newTextAlreadyInListIndex will be row of duplicate or
460     /// -1 if new text is unique
461     int newTextAlreadyInListIndex = -1;
462     for (int r = values.count() - 1; newTextAlreadyInListIndex < 0 && r >= 0; --r) {
463         if (row != r && values[r].text == newText)
464             newTextAlreadyInListIndex = r;
465     }
466 
467     if (newTextAlreadyInListIndex < 0) {
468         /// User-entered text is unique, so simply replace
469         /// old text with new text
470         values[row].text = newText;
471         values[row].value = newValue;
472         const QSharedPointer<Person> person = newValue.first().dynamicCast<Person>();
473         values[row].sortBy = person.isNull() ? QString() : person->lastName() + QStringLiteral(" ") + person->firstName();
474     } else {
475         /// The user-entered text existed before
476 
477         const int lastRow = values.count() - 1;
478         if (row != lastRow) {
479             /// Unless duplicate is last one in list,
480             /// overwrite edited row with last row's value
481             values[row].text = values[lastRow].text;
482             values[row].value = values[lastRow].value;
483             values[row].sortBy = values[lastRow].sortBy;
484         }
485 
486         /// Remove last row, which is no longer used
487         beginRemoveRows(QModelIndex(), lastRow, lastRow);
488         values.remove(lastRow);
489         endRemoveRows();
490     }
491 
492     /// Notify Qt about data changed
493     emit dataChanged(index, index);
494 
495     return true;
496 }
497 
removeValueFromEntries(const QModelIndex & index)498 void ValueListModel::removeValueFromEntries(const QModelIndex &index)
499 {
500     /// Retrieve the Value object containing the user-entered data
501     const Value toBeDeletedValue = values[index.row()].value;
502     if (toBeDeletedValue.isEmpty()) {
503         return;
504     }
505     const QString toBeDeletedText = PlainTextValue::text(toBeDeletedValue);
506     if (toBeDeletedText.isEmpty()) {
507         return;
508     }
509 
510     /// Go through all elements in the current file
511     for (const QSharedPointer<Element> &element : const_cast<const File &>(*file)) {
512         QSharedPointer<Entry> entry = element.dynamicCast<Entry>();
513         /// Process only Entry objects
514         if (!entry.isNull()) {
515             /// Go through every key-value pair in entry (author, title, ...)
516             for (Entry::Iterator eit = entry->begin(); eit != entry->end(); ++eit) {
517                 /// Fetch key-value pair's key
518                 const QString key = eit.key().toLower();
519                 /// Process only key-value pairs that are filtered for (e.g. only keywords)
520                 if (key == fName) {
521                     /// Fetch the key-value pair's value's textual representation
522                     const QString valueFullText = PlainTextValue::text(eit.value());
523                     if (valueFullText == toBeDeletedText) {
524                         /// If the key-value pair's value's textual representation is the same
525                         /// as the value to be delted, remove this key-value pair
526                         /// This test is usually true for keys like title, year, or edition.
527                         entry->remove(key); /// This would break the Iterator, but code "breakes" from loop anyways
528                     } else {
529                         /// The test above failed, but the delete operation may have
530                         /// to be applied to a ValueItem inside the value.
531                         /// Possible keys for such a case include author, editor, or keywords.
532 
533                         /// Process each ValueItem inside this Value
534                         for (Value::Iterator vit = eit.value().begin(); vit != eit.value().end();) {
535                             /// Similar procedure as for full values above:
536                             /// If a ValueItem's textual representation is the same
537                             /// as the shown string which has be deleted, remove the
538                             /// ValueItem from this Value. If the Value becomes empty,
539                             /// remove Value as well.
540                             const QString valueItemText = PlainTextValue::text(* (*vit));
541                             if (valueItemText == toBeDeletedText) {
542                                 /// Erase old ValueItem from this Value
543                                 vit = eit.value().erase(vit);
544                             } else
545                                 ++vit;
546                         }
547 
548                         if (eit.value().isEmpty()) {
549                             /// This value does no longer contain any ValueItems.
550                             entry->remove(key); /// This would break the Iterator, but code "breakes" from loop anyways
551                         }
552                     }
553                     break;
554                 }
555             }
556         }
557     }
558 }
559 
removeValueFromModel(const QModelIndex & index)560 void ValueListModel::removeValueFromModel(const QModelIndex &index)
561 {
562     const int row = index.row();
563     const int lastRow = values.count() - 1;
564 
565     if (row != lastRow) {
566         /// Unless duplicate is last one in list,
567         /// overwrite edited row with last row's value
568         values[row].text = values[lastRow].text;
569         values[row].value = values[lastRow].value;
570         values[row].sortBy = values[lastRow].sortBy;
571 
572         emit dataChanged(index, index);
573     }
574 
575     /// Remove last row, which is no longer used
576     beginRemoveRows(QModelIndex(), lastRow, lastRow);
577     values.remove(lastRow);
578     endRemoveRows();
579 }
580