1 #include "contentmodel.hpp"
2 #include "esmfile.hpp"
3 
4 #include <stdexcept>
5 
6 #include <QDir>
7 #include <QTextCodec>
8 #include <QDebug>
9 
10 #include <components/esm/esmreader.hpp>
11 
ContentModel(QObject * parent,QIcon warningIcon)12 ContentSelectorModel::ContentModel::ContentModel(QObject *parent, QIcon warningIcon) :
13     QAbstractTableModel(parent),
14     mWarningIcon(warningIcon),
15     mMimeType ("application/omwcontent"),
16     mMimeTypes (QStringList() << mMimeType),
17     mColumnCount (1),
18     mDropActions (Qt::MoveAction)
19 {
20     setEncoding ("win1252");
21     uncheckAll();
22 }
23 
~ContentModel()24 ContentSelectorModel::ContentModel::~ContentModel()
25 {
26     qDeleteAll(mFiles);
27     mFiles.clear();
28 }
29 
setEncoding(const QString & encoding)30 void ContentSelectorModel::ContentModel::setEncoding(const QString &encoding)
31 {
32     mEncoding = encoding;
33 }
34 
columnCount(const QModelIndex & parent) const35 int ContentSelectorModel::ContentModel::columnCount(const QModelIndex &parent) const
36 {
37     if (parent.isValid())
38         return 0;
39 
40     return mColumnCount;
41 }
42 
rowCount(const QModelIndex & parent) const43 int ContentSelectorModel::ContentModel::rowCount(const QModelIndex &parent) const
44 {
45     if(parent.isValid())
46         return 0;
47 
48     return mFiles.size();
49 }
50 
item(int row) const51 const ContentSelectorModel::EsmFile *ContentSelectorModel::ContentModel::item(int row) const
52 {
53     if (row >= 0 && row < mFiles.size())
54         return mFiles.at(row);
55 
56     return nullptr;
57 }
58 
item(int row)59 ContentSelectorModel::EsmFile *ContentSelectorModel::ContentModel::item(int row)
60 {
61     if (row >= 0 && row < mFiles.count())
62         return mFiles.at(row);
63 
64     return nullptr;
65 }
item(const QString & name) const66 const ContentSelectorModel::EsmFile *ContentSelectorModel::ContentModel::item(const QString &name) const
67 {
68     EsmFile::FileProperty fp = EsmFile::FileProperty_FileName;
69 
70     if (name.contains ('/'))
71         fp = EsmFile::FileProperty_FilePath;
72 
73     for (const EsmFile *file : mFiles)
74     {
75         if (name.compare(file->fileProperty (fp).toString(), Qt::CaseInsensitive) == 0)
76             return file;
77     }
78     return nullptr;
79 }
80 
indexFromItem(const EsmFile * item) const81 QModelIndex ContentSelectorModel::ContentModel::indexFromItem(const EsmFile *item) const
82 {
83     //workaround: non-const pointer cast for calls from outside contentmodel/contentselector
84     EsmFile *non_const_file_ptr = const_cast<EsmFile *>(item);
85 
86     if (item)
87         return index(mFiles.indexOf(non_const_file_ptr),0);
88 
89     return QModelIndex();
90 }
91 
flags(const QModelIndex & index) const92 Qt::ItemFlags ContentSelectorModel::ContentModel::flags(const QModelIndex &index) const
93 {
94     if (!index.isValid())
95         return Qt::ItemIsDropEnabled;
96 
97     const EsmFile *file = item(index.row());
98 
99     if (!file)
100         return Qt::NoItemFlags;
101 
102     //game files can always be checked
103     if (file->isGameFile())
104         return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsUserCheckable;
105 
106     Qt::ItemFlags returnFlags;
107 
108     // addon can be checked if its gamefile is
109     // ... special case, addon with no dependency can be used with any gamefile.
110     bool gamefileChecked = (file->gameFiles().count() == 0);
111     for (const QString &fileName : file->gameFiles())
112     {
113         for (QListIterator<EsmFile *> dependencyIter(mFiles); dependencyIter.hasNext(); dependencyIter.next())
114         {
115             //compare filenames only.  Multiple instances
116             //of the filename (with different paths) is not relevant here.
117             bool depFound = (dependencyIter.peekNext()->fileName().compare(fileName, Qt::CaseInsensitive) == 0);
118 
119             if (!depFound)
120                 continue;
121 
122             if (!gamefileChecked)
123             {
124                 if (isChecked (dependencyIter.peekNext()->filePath()))
125                     gamefileChecked = (dependencyIter.peekNext()->isGameFile());
126             }
127 
128             // force it to iterate all files in cases where the current
129             // dependency is a game file to ensure that a later duplicate
130             // game file is / is not checked.
131             // (i.e., break only if it's not a gamefile or the game file has been checked previously)
132             if (gamefileChecked || !(dependencyIter.peekNext()->isGameFile()))
133                 break;
134         }
135     }
136 
137     if (gamefileChecked)
138     {
139         returnFlags = Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsUserCheckable | Qt::ItemIsDragEnabled;
140     }
141 
142     return returnFlags;
143 }
144 
data(const QModelIndex & index,int role) const145 QVariant ContentSelectorModel::ContentModel::data(const QModelIndex &index, int role) const
146 {
147     if (!index.isValid())
148         return QVariant();
149 
150     if (index.row() >= mFiles.size())
151         return QVariant();
152 
153     const EsmFile *file = item(index.row());
154 
155     if (!file)
156         return QVariant();
157 
158     const int column = index.column();
159 
160     switch (role)
161     {
162     case Qt::DecorationRole:
163     {
164         return isLoadOrderError(file) ? mWarningIcon : QVariant();
165     }
166 
167     case Qt::EditRole:
168     case Qt::DisplayRole:
169     {
170         if (column >=0 && column <=EsmFile::FileProperty_GameFile)
171             return file->fileProperty(static_cast<EsmFile::FileProperty>(column));
172 
173         return QVariant();
174     }
175 
176     case Qt::TextAlignmentRole:
177     {
178         switch (column)
179         {
180         case 0:
181         case 1:
182             return Qt::AlignLeft + Qt::AlignVCenter;
183         case 2:
184         case 3:
185             return Qt::AlignRight + Qt::AlignVCenter;
186         default:
187             return Qt::AlignLeft + Qt::AlignVCenter;
188         }
189     }
190 
191     case Qt::ToolTipRole:
192     {
193         if (column != 0)
194             return QVariant();
195 
196         return toolTip(file);
197     }
198 
199     case Qt::CheckStateRole:
200     {
201         if (file->isGameFile())
202             return QVariant();
203 
204         return mCheckStates[file->filePath()];
205     }
206 
207     case Qt::UserRole:
208     {
209         if (file->isGameFile())
210             return ContentType_GameFile;
211         else
212             if (flags(index))
213                 return ContentType_Addon;
214 
215         break;
216     }
217 
218     case Qt::UserRole + 1:
219         return isChecked(file->filePath());
220     }
221     return QVariant();
222 }
223 
setData(const QModelIndex & index,const QVariant & value,int role)224 bool ContentSelectorModel::ContentModel::setData(const QModelIndex &index, const QVariant &value, int role)
225 {
226     if(!index.isValid())
227         return false;
228 
229     EsmFile *file = item(index.row());
230     QString fileName = file->fileName();
231     bool success = false;
232 
233     switch(role)
234     {
235         case Qt::EditRole:
236         {
237             QStringList list = value.toStringList();
238 
239             for (int i = 0; i < EsmFile::FileProperty_GameFile; i++)
240                 file->setFileProperty(static_cast<EsmFile::FileProperty>(i), list.at(i));
241 
242             for (int i = EsmFile::FileProperty_GameFile; i < list.size(); i++)
243                 file->setFileProperty (EsmFile::FileProperty_GameFile, list.at(i));
244 
245             emit dataChanged(index, index);
246 
247             success = true;
248         }
249         break;
250 
251         case Qt::UserRole+1:
252         {
253             success = (flags (index) & Qt::ItemIsEnabled);
254 
255             if (success)
256             {
257                 success = setCheckState(file->filePath(), value.toBool());
258                 emit dataChanged(index, index);
259             }
260         }
261         break;
262 
263         case Qt::CheckStateRole:
264         {
265             int checkValue = value.toInt();
266             bool setState = false;
267             if ((checkValue==Qt::Checked) && !isChecked(file->filePath()))
268             {
269                 setState = true;
270                 success = true;
271             }
272             else if ((checkValue == Qt::Checked) && isChecked (file->filePath()))
273                 setState = true;
274             else if (checkValue == Qt::Unchecked)
275                 setState = true;
276 
277             if (setState)
278             {
279                 setCheckState(file->filePath(), success);
280                 emit dataChanged(index, index);
281                 checkForLoadOrderErrors();
282             }
283             else
284                 return success;
285 
286             for (EsmFile *file2 : mFiles)
287             {
288                 if (file2->gameFiles().contains(fileName, Qt::CaseInsensitive))
289                 {
290                     QModelIndex idx = indexFromItem(file2);
291                     emit dataChanged(idx, idx);
292                 }
293             }
294 
295             success =  true;
296         }
297         break;
298     }
299 
300     return success;
301 }
302 
insertRows(int position,int rows,const QModelIndex & parent)303 bool ContentSelectorModel::ContentModel::insertRows(int position, int rows, const QModelIndex &parent)
304 {
305     if (parent.isValid())
306         return false;
307 
308     beginInsertRows(parent, position, position+rows-1);
309     {
310         for (int row = 0; row < rows; ++row)
311             mFiles.insert(position, new EsmFile);
312 
313     } endInsertRows();
314 
315     return true;
316 }
317 
removeRows(int position,int rows,const QModelIndex & parent)318 bool ContentSelectorModel::ContentModel::removeRows(int position, int rows, const QModelIndex &parent)
319 {
320     if (parent.isValid())
321         return false;
322 
323     beginRemoveRows(parent, position, position+rows-1);
324     {
325         for (int row = 0; row < rows; ++row)
326             delete mFiles.takeAt(position);
327 
328     } endRemoveRows();
329 
330     // at this point we know that drag and drop has finished.
331     checkForLoadOrderErrors();
332     return true;
333 }
334 
supportedDropActions() const335 Qt::DropActions ContentSelectorModel::ContentModel::supportedDropActions() const
336 {
337     return mDropActions;
338 }
339 
mimeTypes() const340 QStringList ContentSelectorModel::ContentModel::mimeTypes() const
341 {
342     return mMimeTypes;
343 }
344 
mimeData(const QModelIndexList & indexes) const345 QMimeData *ContentSelectorModel::ContentModel::mimeData(const QModelIndexList &indexes) const
346 {
347     QByteArray encodedData;
348 
349     for (const QModelIndex &index : indexes)
350     {
351         if (!index.isValid())
352             continue;
353 
354         encodedData.append(item(index.row())->encodedData());
355     }
356 
357     QMimeData *mimeData = new QMimeData();
358     mimeData->setData(mMimeType, encodedData);
359 
360     return mimeData;
361 }
362 
dropMimeData(const QMimeData * data,Qt::DropAction action,int row,int column,const QModelIndex & parent)363 bool ContentSelectorModel::ContentModel::dropMimeData(const QMimeData *data, Qt::DropAction action, int row, int column, const QModelIndex &parent)
364 {
365     if (action == Qt::IgnoreAction)
366         return true;
367 
368     if (column > 0)
369         return false;
370 
371     if (!data->hasFormat(mMimeType))
372         return false;
373 
374     int beginRow = rowCount();
375 
376     if (row != -1)
377         beginRow = row;
378 
379     else if (parent.isValid())
380         beginRow = parent.row();
381 
382     QByteArray encodedData = data->data(mMimeType);
383     QDataStream stream(&encodedData, QIODevice::ReadOnly);
384 
385     while (!stream.atEnd())
386     {
387 
388         QString value;
389         QStringList values;
390         QStringList gamefiles;
391 
392         for (int i = 0; i < EsmFile::FileProperty_GameFile; ++i)
393         {
394             stream >> value;
395             values << value;
396         }
397 
398         stream >> gamefiles;
399 
400         insertRows(beginRow, 1);
401 
402         QModelIndex idx = index(beginRow++, 0, QModelIndex());
403         setData(idx, QStringList() << values << gamefiles, Qt::EditRole);
404     }
405 
406     return true;
407 }
408 
addFile(EsmFile * file)409 void ContentSelectorModel::ContentModel::addFile(EsmFile *file)
410 {
411     beginInsertRows(QModelIndex(), mFiles.count(), mFiles.count());
412         mFiles.append(file);
413     endInsertRows();
414 
415     QModelIndex idx = index (mFiles.size() - 2, 0, QModelIndex());
416 
417     emit dataChanged (idx, idx);
418 }
419 
addFiles(const QString & path)420 void ContentSelectorModel::ContentModel::addFiles(const QString &path)
421 {
422     QDir dir(path);
423     QStringList filters;
424     filters << "*.esp" << "*.esm" << "*.omwgame" << "*.omwaddon";
425     dir.setNameFilters(filters);
426 
427     for (const QString &path2 : dir.entryList())
428     {
429         QFileInfo info(dir.absoluteFilePath(path2));
430 
431         if (item(info.fileName()))
432             continue;
433 
434         try {
435             ESM::ESMReader fileReader;
436             ToUTF8::Utf8Encoder encoder =
437             ToUTF8::calculateEncoding(mEncoding.toStdString());
438             fileReader.setEncoder(&encoder);
439             fileReader.open(std::string(dir.absoluteFilePath(path2).toUtf8().constData()));
440 
441             EsmFile *file = new EsmFile(path2);
442 
443             for (std::vector<ESM::Header::MasterData>::const_iterator itemIter = fileReader.getGameFiles().begin();
444                 itemIter != fileReader.getGameFiles().end(); ++itemIter)
445                 file->addGameFile(QString::fromUtf8(itemIter->name.c_str()));
446 
447             file->setAuthor     (QString::fromUtf8(fileReader.getAuthor().c_str()));
448             file->setDate       (info.lastModified());
449             file->setFormat     (fileReader.getFormat());
450             file->setFilePath       (info.absoluteFilePath());
451             file->setDescription(QString::fromUtf8(fileReader.getDesc().c_str()));
452 
453             // HACK
454             // Load order constraint of Bloodmoon.esm needing Tribunal.esm is missing
455             // from the file supplied by Bethesda, so we have to add it ourselves
456             if (file->fileName().compare("Bloodmoon.esm", Qt::CaseInsensitive) == 0)
457             {
458                 file->addGameFile(QString::fromUtf8("Tribunal.esm"));
459             }
460 
461             // Put the file in the table
462             addFile(file);
463 
464         } catch(std::runtime_error &e) {
465             // An error occurred while reading the .esp
466             qWarning() << "Error reading addon file: " << e.what();
467             continue;
468         }
469 
470     }
471 
472     sortFiles();
473 }
474 
clearFiles()475 void ContentSelectorModel::ContentModel::clearFiles()
476 {
477     const int filesCount = mFiles.count();
478 
479     if (filesCount > 0) {
480         beginRemoveRows(QModelIndex(), 0, filesCount - 1);
481         mFiles.clear();
482         endRemoveRows();
483     }
484 }
485 
gameFiles() const486 QStringList ContentSelectorModel::ContentModel::gameFiles() const
487 {
488     QStringList gameFiles;
489     for (const ContentSelectorModel::EsmFile *file : mFiles)
490     {
491         if (file->isGameFile())
492         {
493             gameFiles.append(file->fileName());
494         }
495     }
496     return gameFiles;
497 }
498 
sortFiles()499 void ContentSelectorModel::ContentModel::sortFiles()
500 {
501     //first, sort the model such that all dependencies are ordered upstream (gamefile) first.
502     bool movedFiles = true;
503     int fileCount = mFiles.size();
504 
505     //Dependency sort
506     //iterate until no sorting of files occurs
507     while (movedFiles)
508     {
509         movedFiles = false;
510         //iterate each file, obtaining a reference to it's gamefiles list
511         for (int i = 0; i < fileCount; i++)
512         {
513             QModelIndex idx1 = index (i, 0, QModelIndex());
514             const QStringList &gamefiles = mFiles.at(i)->gameFiles();
515             //iterate each file after the current file, verifying that none of it's
516             //dependencies appear.
517             for (int j = i + 1; j < fileCount; j++)
518             {
519                 if (gamefiles.contains(mFiles.at(j)->fileName(), Qt::CaseInsensitive)
520                  || (!mFiles.at(i)->isGameFile() && gamefiles.isEmpty()
521                  && mFiles.at(j)->fileName().compare("Morrowind.esm", Qt::CaseInsensitive) == 0)) // Hack: implicit dependency on Morrowind.esm for dependency-less files
522                 {
523                         mFiles.move(j, i);
524 
525                         QModelIndex idx2 = index (j, 0, QModelIndex());
526 
527                         emit dataChanged (idx1, idx2);
528 
529                         movedFiles = true;
530                 }
531             }
532             if (movedFiles)
533                 break;
534         }
535     }
536 }
537 
isChecked(const QString & filepath) const538 bool ContentSelectorModel::ContentModel::isChecked(const QString& filepath) const
539 {
540     if (mCheckStates.contains(filepath))
541         return (mCheckStates[filepath] == Qt::Checked);
542 
543     return false;
544 }
545 
isEnabled(QModelIndex index) const546 bool ContentSelectorModel::ContentModel::isEnabled (QModelIndex index) const
547 {
548     return (flags(index) & Qt::ItemIsEnabled);
549 }
550 
isLoadOrderError(const EsmFile * file) const551 bool ContentSelectorModel::ContentModel::isLoadOrderError(const EsmFile *file) const
552 {
553     return mPluginsWithLoadOrderError.contains(file->filePath());
554 }
555 
setContentList(const QStringList & fileList)556 void ContentSelectorModel::ContentModel::setContentList(const QStringList &fileList)
557 {
558     mPluginsWithLoadOrderError.clear();
559     int previousPosition = -1;
560     for (const QString &filepath : fileList)
561     {
562         if (setCheckState(filepath, true))
563         {
564             // as necessary, move plug-ins in visible list to match sequence of supplied filelist
565             const EsmFile* file = item(filepath);
566             int filePosition = indexFromItem(file).row();
567             if (filePosition < previousPosition)
568             {
569                 mFiles.move(filePosition, previousPosition);
570                 emit dataChanged(index(filePosition, 0, QModelIndex()), index(previousPosition, 0, QModelIndex()));
571             }
572             else
573             {
574                 previousPosition = filePosition;
575             }
576         }
577     }
578     checkForLoadOrderErrors();
579 }
580 
checkForLoadOrderErrors()581 void ContentSelectorModel::ContentModel::checkForLoadOrderErrors()
582 {
583     for (int row = 0; row < mFiles.count(); ++row)
584     {
585         EsmFile* file = item(row);
586         bool isRowInError = checkForLoadOrderErrors(file, row).count() != 0;
587         if (isRowInError)
588         {
589             mPluginsWithLoadOrderError.insert(file->filePath());
590         }
591         else
592         {
593             mPluginsWithLoadOrderError.remove(file->filePath());
594         }
595     }
596 }
597 
checkForLoadOrderErrors(const EsmFile * file,int row) const598 QList<ContentSelectorModel::LoadOrderError> ContentSelectorModel::ContentModel::checkForLoadOrderErrors(const EsmFile *file, int row) const
599 {
600     QList<LoadOrderError> errors = QList<LoadOrderError>();
601     for (const QString &dependentfileName : file->gameFiles())
602     {
603         const EsmFile* dependentFile = item(dependentfileName);
604 
605         if (!dependentFile)
606         {
607             errors.append(LoadOrderError(LoadOrderError::ErrorCode_MissingDependency, dependentfileName));
608         }
609         else
610         {
611             if (!isChecked(dependentFile->filePath()))
612             {
613                 errors.append(LoadOrderError(LoadOrderError::ErrorCode_InactiveDependency, dependentfileName));
614             }
615             if (row < indexFromItem(dependentFile).row())
616             {
617                 errors.append(LoadOrderError(LoadOrderError::ErrorCode_LoadOrder, dependentfileName));
618             }
619         }
620     }
621     return errors;
622 }
623 
toolTip(const EsmFile * file) const624 QString ContentSelectorModel::ContentModel::toolTip(const EsmFile *file) const
625 {
626     if (isLoadOrderError(file))
627     {
628         QString text("<b>");
629         int index = indexFromItem(item(file->filePath())).row();
630         for (const LoadOrderError& error : checkForLoadOrderErrors(file, index))
631         {
632             text += "<p>";
633             text += error.toolTip();
634             text += "</p>";
635         }
636         text += ("</b>");
637         text += file->toolTip();
638         return text;
639     }
640     else
641     {
642         return file->toolTip();
643     }
644 }
645 
refreshModel()646 void ContentSelectorModel::ContentModel::refreshModel()
647 {
648     emit dataChanged (index(0,0), index(rowCount()-1,0));
649 }
650 
setCheckState(const QString & filepath,bool checkState)651 bool ContentSelectorModel::ContentModel::setCheckState(const QString &filepath, bool checkState)
652 {
653     if (filepath.isEmpty())
654         return false;
655 
656     const EsmFile *file = item(filepath);
657 
658     if (!file)
659         return false;
660 
661     Qt::CheckState state = Qt::Unchecked;
662 
663     if (checkState)
664         state = Qt::Checked;
665 
666     mCheckStates[filepath] = state;
667     emit dataChanged(indexFromItem(item(filepath)), indexFromItem(item(filepath)));
668 
669     if (file->isGameFile())
670         refreshModel();
671 
672     //if we're checking an item, ensure all "upstream" files (dependencies) are checked as well.
673     if (state == Qt::Checked)
674     {
675         for (const QString& upstreamName : file->gameFiles())
676         {
677             const EsmFile *upstreamFile = item(upstreamName);
678 
679             if (!upstreamFile)
680                 continue;
681 
682             if (!isChecked(upstreamFile->filePath()))
683                 mCheckStates[upstreamFile->filePath()] = Qt::Checked;
684 
685             emit dataChanged(indexFromItem(upstreamFile), indexFromItem(upstreamFile));
686 
687         }
688     }
689     //otherwise, if we're unchecking an item (or the file is a game file) ensure all downstream files are unchecked.
690     if (state == Qt::Unchecked)
691     {
692         for (const EsmFile *downstreamFile : mFiles)
693         {
694             QFileInfo fileInfo(filepath);
695             QString filename = fileInfo.fileName();
696 
697             if (downstreamFile->gameFiles().contains(filename, Qt::CaseInsensitive))
698             {
699                 if (mCheckStates.contains(downstreamFile->filePath()))
700                     mCheckStates[downstreamFile->filePath()] = Qt::Unchecked;
701 
702                 emit dataChanged(indexFromItem(downstreamFile), indexFromItem(downstreamFile));
703             }
704         }
705     }
706 
707     return true;
708 }
709 
checkedItems() const710 ContentSelectorModel::ContentFileList ContentSelectorModel::ContentModel::checkedItems() const
711 {
712     ContentFileList list;
713 
714     // TODO:
715     // First search for game files and next addons,
716     // so we get more or less correct game files vs addons order.
717     for (EsmFile *file : mFiles)
718         if (isChecked(file->filePath()))
719             list << file;
720 
721     return list;
722 }
723 
uncheckAll()724 void ContentSelectorModel::ContentModel::uncheckAll()
725 {
726     emit layoutAboutToBeChanged();
727     mCheckStates.clear();
728     emit layoutChanged();
729 }
730