summaryrefslogtreecommitdiffstats
path: root/desktop-widgets/findmovedimagesdialog.cpp
blob: a0ef0d9b40f3bf2ab5f33e203a428cafffaeeb59 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
// SPDX-License-Identifier: GPL-2.0
#include "findmovedimagesdialog.h"
#include "core/qthelper.h"
#include "desktop-widgets/divelistview.h"	// TODO: used for lastUsedImageDir()
#include "qt-models/divepicturemodel.h"

#include <QFileDialog>
#include <QtConcurrent>

FindMovedImagesDialog::FindMovedImagesDialog(QWidget *parent) : QDialog(parent)
{
	ui.setupUi(this);
	fontMetrics.reset(new QFontMetrics(ui.scanning->font()));
	connect(&watcher, &QFutureWatcher<QVector<Match>>::finished, this, &FindMovedImagesDialog::searchDone);
	connect(ui.buttonBox->button(QDialogButtonBox::Apply), &QAbstractButton::clicked, this, &FindMovedImagesDialog::apply);
	ui.buttonBox->button(QDialogButtonBox::Apply)->setEnabled(false);
}

// Compare two full paths and return the number of matching levels, starting from the filename.
// String comparison is case-insensitive.
static int matchPath(const QString &path1, const QString &path2)
{
	QFileInfo f1(path1);
	QFileInfo f2(path2);

	int score = 0;
	for (;;) {
		QString fn1 = f1.fileName();
		QString fn2 = f2.fileName();
		if (fn1.isEmpty() || fn2.isEmpty())
			break;
		if (fn1 == ".") {
			f1 = QFileInfo(f1.path());
			continue;
		}
		if (fn2 == ".") {
			f2 = QFileInfo(f2.path());
			continue;
		}
		if (QString::compare(fn1, fn2, Qt::CaseInsensitive) != 0)
			break;
		f1 = QFileInfo(f1.path());
		f2 = QFileInfo(f2.path());
		++score;
	}
	return score;
}

FindMovedImagesDialog::ImagePath::ImagePath(const QString &path) : fullPath(path),
	filenameUpperCase(QFileInfo(path).fileName().toUpper())
{
}

bool FindMovedImagesDialog::ImagePath::operator<(const ImagePath &path2) const
{
	return filenameUpperCase < path2.filenameUpperCase;
}

void FindMovedImagesDialog::learnImage(const QString &filename, QMap<QString, ImageMatch> &matches, const QVector<ImagePath> &imagePaths)
{
	QStringList newMatches;
	int bestScore = 1;
	// Find matching file paths by a binary search of the file name
	ImagePath path(filename);
	for (auto it = std::lower_bound(imagePaths.begin(), imagePaths.end(), path);
	     it != imagePaths.end() && it->filenameUpperCase == path.filenameUpperCase;
	     ++it) {
		int score = matchPath(filename, it->fullPath);
		if (score < bestScore)
			continue;
		if (score > bestScore)
			newMatches.clear();
		newMatches.append(it->fullPath);
		bestScore = score;
	}

	// Add the new original filenames to the list of matches, if the score is higher than previously
	for (const QString &originalFilename: newMatches) {
		auto it = matches.find(originalFilename);
		if (it == matches.end())
			matches.insert(originalFilename, { filename, bestScore });
		else if (it->score < bestScore)
			*it = { filename, bestScore };
	}
}

// We use a stack to recurse into directories. Each level of the stack is made up of
// a list of subdirectories to process. For each directory we keep track of the progress
// that is done when processing this directory. In principle the from value is redundant
// (it could be extracted from previous stack entries, but it makes code simpler.
struct Dir {
	QString path;
	double progressFrom, progressTo;
};

QVector<FindMovedImagesDialog::Match> FindMovedImagesDialog::learnImages(const QString &rootdir, int maxRecursions, QVector<QString> imagePathsIn)
{
	QMap<QString, ImageMatch> matches;

	// For divelogs with thousands of images, we don't want to compare the path of every image.
	// Therefore, keep an array of image paths sorted by the filename in upper case.
	// Thus, we can access all paths ending in the same filename by a binary search. We suppose that
	// there aren't many pictures with the same filename but different paths.
	QVector<ImagePath> imagePaths;
	imagePaths.reserve(imagePathsIn.size());
	for (const QString &path: imagePathsIn)
		imagePaths.append(ImagePath(path));	// No emplace() in QVector? Sheesh.
	std::sort(imagePaths.begin(), imagePaths.end());

	// Free memory of original path vector - we don't need it any more
	imagePathsIn.clear();

	QVector<QVector<Dir>> stack; // Use a stack to recurse into directories
	stack.reserve(maxRecursions + 1);
	stack.append({ { rootdir, 0.0, 1.0 } });
	while (!stack.isEmpty()) {
		if (stack.last().isEmpty()) {
			stack.removeLast();
			continue;
		}
		Dir entry = stack.last().takeLast();
		QDir dir(entry.path);

		// Since we're running in a different thread, use invokeMethod to set progress.
		QMetaObject::invokeMethod(this, "setProgress", Q_ARG(double, entry.progressFrom), Q_ARG(QString, dir.absolutePath()));

		for (const QString &file: dir.entryList(QDir::Files)) {
			if (stopScanning != 0)
				goto out;
			learnImage(dir.absoluteFilePath(file), matches, imagePaths);
		}
		if (stack.size() <= maxRecursions) {
			stack.append(QVector<Dir>());
			QVector<Dir> &newItem = stack.last();
			for (const QString &dirname: dir.entryList(QDir::NoDotAndDotDot | QDir::Dirs))
				stack.last().append({ dir.filePath(dirname), 0.0, 0.0 });
			int num = newItem.size();
			double diff = entry.progressTo - entry.progressFrom;
			// We pop from back therefore we fill the progress in reverse
			for (int i = 0; i < num; ++i) {
				newItem[num - i - 1].progressFrom = (i / (double)num) * diff + entry.progressFrom;
				newItem[num - i - 1].progressTo = ((i + 1) / (double)num) * diff + entry.progressFrom;
			}
		}
	}
out:
	QMetaObject::invokeMethod(this, "setProgress", Q_ARG(double, 1.0), Q_ARG(QString, QString()));
	QVector<FindMovedImagesDialog::Match> ret;
	for (auto it = matches.begin(); it != matches.end(); ++it)
		ret.append({ it.key(), it->localFilename, it->score });
	return ret;
}

void FindMovedImagesDialog::setProgress(double progress, QString path)
{
	ui.progress->setValue((int)(progress * 100.0));

	// Elide text to avoid rescaling of the window if path is too long.
	// Note that we subtract an arbitrary 10 pixels from the width, because otherwise the label slowly grows.
	QString elidedPath = fontMetrics->elidedText(path, Qt::ElideMiddle, ui.scanning->width() - 10);
	ui.scanning->setText(elidedPath);
}

void FindMovedImagesDialog::on_scanButton_clicked()
{
	if (watcher.isRunning()) {
		stopScanning = 1;
		return;
	}

	// TODO: is lastUsedImageDir really well-placed in DiveListView?
	QString dirName = QFileDialog::getExistingDirectory(this,
							    tr("Traverse media directories"),
							    DiveListView::lastUsedImageDir(),
							    QFileDialog::ShowDirsOnly);
	if (dirName.isEmpty())
		return;
	DiveListView::updateLastUsedImageDir(dirName);
	ui.scanButton->setText(tr("Stop scanning"));
	ui.buttonBox->setEnabled(false);
	ui.imagesText->clear();
	// We have to collect the names of the image filenames in the main thread
	bool onlySelected = ui.onlySelectedDives->isChecked();
	QVector<QString> imagePaths;
	int i;
	struct dive *dive;
	for_each_dive (i, dive)
		if (!onlySelected || dive->selected)
			FOR_EACH_PICTURE(dive)
				imagePaths.append(QString(picture->filename));
	stopScanning = 0;
	QFuture<QVector<Match>> future = QtConcurrent::run(
			// Note that we capture everything but "this" by copy to avoid dangling references.
			[this, dirName, imagePaths]()
			{ return learnImages(dirName, 20, imagePaths);}
	);
	watcher.setFuture(future);
}

static QString formatPath(const QString &path, int numBold)
{
	QString res;
	QVector<QString> boldPaths;
	boldPaths.reserve(numBold);
	QFileInfo info(path);
	for (int i = 0; i < numBold; ++i) {
		QString fn = info.fileName();
		if (fn.isEmpty())
			break;
		boldPaths.append(fn);
		info = QFileInfo(info.path());
	}
	QString nonBoldPath = info.filePath();
	QString separator = QDir::separator();
	if (!nonBoldPath.isEmpty()) {
		res += nonBoldPath.toHtmlEscaped();
		if (!boldPaths.isEmpty() && nonBoldPath[nonBoldPath.size() - 1] != QDir::separator())
			res += separator;
	}

	if (boldPaths.size() > 0) {
		res += "<b>";
		for (int i = boldPaths.size() - 1; i >= 0; --i) {
			res += boldPaths[i].toHtmlEscaped();
			if (i > 0)
				res += separator;
		}
		res += "</b>";
	}
	return res;
}

static bool sameFile(const QString &f1, const QString &f2)
{
	return QFileInfo(f1) == QFileInfo(f2);
}

void FindMovedImagesDialog::searchDone()
{
	ui.scanButton->setText(tr("Select folder and scan"));
	ui.buttonBox->setEnabled(true);
	ui.scanning->clear();

	matches = watcher.result();
	ui.imagesText->clear();

	QString text;
	int numChanged = 0;
	if (stopScanning != 0) {
		text += "<b>" + tr("Scanning cancelled - results may be incomplete") + "</b><br/>";
		stopScanning = 0;
	}
	if (matches.isEmpty()) {
		text += "<i>" + tr("No matching media files found") + "</i>";
	} else {
		QString matchesText;
		for (const Match &match: matches) {
			if (!sameFile(match.localFilename, localFilePath(match.originalFilename))) {
				++numChanged;
				matchesText += formatPath(match.originalFilename, match.matchingPathItems) + " → " +
					       formatPath(match.localFilename, match.matchingPathItems) + "<br/>";
			}
		}
		int numUnchanged = matches.size() - numChanged;
		if (numUnchanged > 0)
			text += tr("Found <b>%1</b> media files at their current place.").arg(numUnchanged) + "<br/>";
		if (numChanged > 0) {
			text += tr("Found <b>%1</b> media files at new locations:").arg(numChanged) + "<br/>";
			text += matchesText;
		}
	}
	ui.imagesText->setHtml(text);
	ui.buttonBox->button(QDialogButtonBox::Apply)->setEnabled(numChanged > 0);
}

void FindMovedImagesDialog::apply()
{
	for (const Match &match: matches)
		learnPictureFilename(match.originalFilename, match.localFilename);
	write_hashes();
	DivePictureModel::instance()->updateDivePictures();

	ui.imagesText->clear();
	matches.clear();
	hide();
	close();
}

void FindMovedImagesDialog::on_buttonBox_rejected()
{
	ui.imagesText->clear();
	matches.clear();
}