1/*
2 SPDX-FileCopyrightText: 2010-2018 Dominik Haumann <dhaumann@kde.org>
3 SPDX-FileCopyrightText: 2010 Diana-Victoria Tiriplica <diana.tiriplica@gmail.com>
4
5 SPDX-License-Identifier: LGPL-2.0-or-later
6*/
7
8#include "config.h"
9
10#include "katebuffer.h"
11#include "kateconfig.h"
12#include "katedocument.h"
13#include "katepartdebug.h"
14#include "kateswapdiffcreator.h"
15#include "kateswapfile.h"
16#include "katetextbuffer.h"
17#include "kateundomanager.h"
18#include "ktexteditor/message.h"
19#include <ktexteditor/view.h>
20
21#include <KLocalizedString>
22#include <KStandardGuiItem>
23
24#include <QApplication>
25#include <QCryptographicHash>
26#include <QDir>
27#include <QFileInfo>
28
29#ifndef Q_OS_WIN
30#include <unistd.h>
31#endif
32
33// swap file version header
34const static char swapFileVersionString[] = "Kate Swap File 2.0";
35
36// tokens for swap files
37const static qint8 EA_StartEditing = 'S';
38const static qint8 EA_FinishEditing = 'E';
39const static qint8 EA_WrapLine = 'W';
40const static qint8 EA_UnwrapLine = 'U';
41const static qint8 EA_InsertText = 'I';
42const static qint8 EA_RemoveText = 'R';
43
44namespace Kate
45{
46QTimer *SwapFile::s_timer = nullptr;
47
48SwapFile::SwapFile(KTextEditor::DocumentPrivate *document)
49 : QObject(document)
50 , m_document(document)
51 , m_trackingEnabled(false)
52 , m_recovered(false)
53 , m_needSync(false)
54{
55 // fixed version of serialisation
56 m_stream.setVersion(QDataStream::Qt_4_6);
57
58 // connect the timer
59 connect(sender: syncTimer(), signal: &QTimer::timeout, context: this, slot: &Kate::SwapFile::writeFileToDisk, type: Qt::DirectConnection);
60
61 // connecting the signals
62 connect(sender: &m_document->buffer(), signal: &KateBuffer::saved, context: this, slot: &Kate::SwapFile::fileSaved);
63 connect(sender: &m_document->buffer(), signal: &KateBuffer::loaded, context: this, slot: &Kate::SwapFile::fileLoaded);
64 connect(sender: m_document, signal: &KTextEditor::Document::configChanged, context: this, slot: &SwapFile::configChanged);
65
66 // tracking on!
67 setTrackingEnabled(true);
68}
69
70SwapFile::~SwapFile()
71{
72 // only remove swap file after data recovery (bug #304576)
73 if (!shouldRecover()) {
74 removeSwapFile();
75 }
76}
77
78void SwapFile::configChanged()
79{
80}
81
82void SwapFile::setTrackingEnabled(bool enable)
83{
84 if (m_trackingEnabled == enable) {
85 return;
86 }
87
88 m_trackingEnabled = enable;
89
90 if (m_trackingEnabled) {
91 connect(sender: m_document, signal: &KTextEditor::Document::editingStarted, context: this, slot: &Kate::SwapFile::startEditing);
92 connect(sender: m_document, signal: &KTextEditor::Document::editingFinished, context: this, slot: &Kate::SwapFile::finishEditing);
93 connect(sender: m_document, signal: &KTextEditor::DocumentPrivate::modifiedChanged, context: this, slot: &SwapFile::modifiedChanged);
94
95 connect(sender: m_document, signal: &KTextEditor::Document::lineWrapped, context: this, slot: &Kate::SwapFile::wrapLine);
96 connect(sender: m_document, signal: &KTextEditor::Document::lineUnwrapped, context: this, slot: &Kate::SwapFile::unwrapLine);
97 connect(sender: m_document, signal: &KTextEditor::Document::textInserted, context: this, slot: &Kate::SwapFile::insertText);
98 connect(sender: m_document, signal: &KTextEditor::Document::textRemoved, context: this, slot: &Kate::SwapFile::removeText);
99 } else {
100 disconnect(sender: m_document, signal: &KTextEditor::Document::editingStarted, receiver: this, slot: &Kate::SwapFile::startEditing);
101 disconnect(sender: m_document, signal: &KTextEditor::Document::editingFinished, receiver: this, slot: &Kate::SwapFile::finishEditing);
102 disconnect(sender: m_document, signal: &KTextEditor::DocumentPrivate::modifiedChanged, receiver: this, slot: &SwapFile::modifiedChanged);
103
104 disconnect(sender: m_document, signal: &KTextEditor::Document::lineWrapped, receiver: this, slot: &Kate::SwapFile::wrapLine);
105 disconnect(sender: m_document, signal: &KTextEditor::Document::lineUnwrapped, receiver: this, slot: &Kate::SwapFile::unwrapLine);
106 disconnect(sender: m_document, signal: &KTextEditor::Document::textInserted, receiver: this, slot: &Kate::SwapFile::insertText);
107 disconnect(sender: m_document, signal: &KTextEditor::Document::textRemoved, receiver: this, slot: &Kate::SwapFile::removeText);
108 }
109}
110
111void SwapFile::fileClosed()
112{
113 // remove old swap file, file is now closed
114 if (!shouldRecover()) {
115 removeSwapFile();
116 } else {
117 m_document->setReadWrite(true);
118 }
119
120 // purge filename
121 updateFileName();
122}
123
124KTextEditor::DocumentPrivate *SwapFile::document()
125{
126 return m_document;
127}
128
129bool SwapFile::isValidSwapFile(QDataStream &stream, bool checkDigest) const
130{
131 // read and check header
132 QByteArray header;
133 stream >> header;
134
135 if (header != swapFileVersionString) {
136 qCWarning(LOG_KTE) << "Can't open swap file, wrong version";
137 return false;
138 }
139
140 // read checksum
141 QByteArray checksum;
142 stream >> checksum;
143 // qCDebug(LOG_KTE) << "DIGEST:" << checksum << m_document->checksum();
144 if (checkDigest && checksum != m_document->checksum()) {
145 qCWarning(LOG_KTE) << "Can't recover from swap file, checksum of document has changed";
146 return false;
147 }
148
149 return true;
150}
151
152void SwapFile::fileLoaded(const QString &)
153{
154 // look for swap file
155 if (!updateFileName()) {
156 return;
157 }
158
159 if (!m_swapfile.exists()) {
160 // qCDebug(LOG_KTE) << "No swap file";
161 return;
162 }
163
164 if (!QFileInfo(m_swapfile).isReadable()) {
165 qCWarning(LOG_KTE) << "Can't open swap file (missing permissions)";
166 return;
167 }
168
169 // sanity check
170 QFile peekFile(fileName());
171 if (peekFile.open(flags: QIODevice::ReadOnly)) {
172 QDataStream stream(&peekFile);
173 if (!isValidSwapFile(stream, checkDigest: true)) {
174 removeSwapFile();
175 return;
176 }
177 peekFile.close();
178 } else {
179 qCWarning(LOG_KTE) << "Can't open swap file:" << fileName();
180 return;
181 }
182
183 // show swap file message
184 m_document->setReadWrite(false);
185 showSwapFileMessage();
186}
187
188void SwapFile::modifiedChanged()
189{
190 if (!m_document->isModified() && !shouldRecover()) {
191 m_needSync = false;
192 // the file is not modified and we are not in recover mode
193 removeSwapFile();
194 }
195}
196
197void SwapFile::recover()
198{
199 m_document->setReadWrite(true);
200
201 // if isOpen() returns true, the swap file likely changed already (appended data)
202 // Example: The document was falsely marked as writable and the user changed
203 // text even though the recover bar was visible. In this case, a replay of
204 // the swap file across wrong document content would happen -> certainly wrong
205 if (m_swapfile.isOpen()) {
206 qCWarning(LOG_KTE) << "Attempt to recover an already modified document. Aborting";
207 removeSwapFile();
208 return;
209 }
210
211 // if the file doesn't exist, abort (user might have deleted it, or use two editor instances)
212 if (!m_swapfile.open(flags: QIODevice::ReadOnly)) {
213 qCWarning(LOG_KTE) << "Can't open swap file";
214 return;
215 }
216
217 // remember that the file has recovered
218 m_recovered = true;
219
220 // open data stream
221 m_stream.setDevice(&m_swapfile);
222
223 // replay the swap file
224 bool success = recover(m_stream);
225
226 // close swap file
227 m_stream.setDevice(nullptr);
228 m_swapfile.close();
229
230 if (!success) {
231 removeSwapFile();
232 }
233
234 // recover can also be called through the KTE::RecoveryInterface.
235 // Make sure, the message is hidden in this case as well.
236 if (m_swapMessage) {
237 m_swapMessage->deleteLater();
238 }
239}
240
241bool SwapFile::recover(QDataStream &stream, bool checkDigest)
242{
243 if (!isValidSwapFile(stream, checkDigest)) {
244 return false;
245 }
246
247 // disconnect current signals
248 setTrackingEnabled(false);
249
250 // needed to set undo/redo cursors in a sane way
251 bool firstEditInGroup = false;
252 KTextEditor::Cursor undoCursor = KTextEditor::Cursor::invalid();
253 KTextEditor::Cursor redoCursor = KTextEditor::Cursor::invalid();
254
255 // replay swapfile
256 bool editRunning = false;
257 bool brokenSwapFile = false;
258 while (!stream.atEnd()) {
259 if (brokenSwapFile) {
260 break;
261 }
262
263 qint8 type;
264 stream >> type;
265 switch (type) {
266 case EA_StartEditing: {
267 m_document->editStart();
268 editRunning = true;
269 firstEditInGroup = true;
270 undoCursor = KTextEditor::Cursor::invalid();
271 redoCursor = KTextEditor::Cursor::invalid();
272 break;
273 }
274 case EA_FinishEditing: {
275 m_document->editEnd();
276
277 // empty editStart() / editEnd() groups exist: only set cursor if required
278 if (!firstEditInGroup) {
279 // set undo/redo cursor of last KateUndoGroup of the undo manager
280 m_document->undoManager()->setUndoRedoCursorsOfLastGroup(undoCursor, redoCursor);
281 m_document->undoManager()->undoSafePoint();
282 }
283 firstEditInGroup = false;
284 editRunning = false;
285 break;
286 }
287 case EA_WrapLine: {
288 if (!editRunning) {
289 brokenSwapFile = true;
290 break;
291 }
292
293 int line = 0;
294 int column = 0;
295 stream >> line >> column;
296
297 // emulate buffer unwrapLine with document
298 m_document->editWrapLine(line, col: column, newLine: true);
299
300 // track undo/redo cursor
301 if (firstEditInGroup) {
302 firstEditInGroup = false;
303 undoCursor = KTextEditor::Cursor(line, column);
304 }
305 redoCursor = KTextEditor::Cursor(line + 1, 0);
306
307 break;
308 }
309 case EA_UnwrapLine: {
310 if (!editRunning) {
311 brokenSwapFile = true;
312 break;
313 }
314
315 int line = 0;
316 stream >> line;
317
318 // assert valid line
319 Q_ASSERT(line > 0);
320
321 const int undoColumn = m_document->lineLength(line: line - 1);
322
323 // emulate buffer unwrapLine with document
324 m_document->editUnWrapLine(line: line - 1, removeLine: true, length: 0);
325
326 // track undo/redo cursor
327 if (firstEditInGroup) {
328 firstEditInGroup = false;
329 undoCursor = KTextEditor::Cursor(line, 0);
330 }
331 redoCursor = KTextEditor::Cursor(line - 1, undoColumn);
332
333 break;
334 }
335 case EA_InsertText: {
336 if (!editRunning) {
337 brokenSwapFile = true;
338 break;
339 }
340
341 int line;
342 int column;
343 QByteArray text;
344 stream >> line >> column >> text;
345 m_document->insertText(position: KTextEditor::Cursor(line, column), s: QString::fromUtf8(utf8: text.data(), size: text.size()));
346
347 // track undo/redo cursor
348 if (firstEditInGroup) {
349 firstEditInGroup = false;
350 undoCursor = KTextEditor::Cursor(line, column);
351 }
352 redoCursor = KTextEditor::Cursor(line, column + text.size());
353
354 break;
355 }
356 case EA_RemoveText: {
357 if (!editRunning) {
358 brokenSwapFile = true;
359 break;
360 }
361
362 int line;
363 int startColumn;
364 int endColumn;
365 stream >> line >> startColumn >> endColumn;
366 m_document->removeText(range: KTextEditor::Range(KTextEditor::Cursor(line, startColumn), KTextEditor::Cursor(line, endColumn)));
367
368 // track undo/redo cursor
369 if (firstEditInGroup) {
370 firstEditInGroup = false;
371 undoCursor = KTextEditor::Cursor(line, endColumn);
372 }
373 redoCursor = KTextEditor::Cursor(line, startColumn);
374
375 break;
376 }
377 default: {
378 qCWarning(LOG_KTE) << "Unknown type:" << type;
379 }
380 }
381 }
382
383 // balanced editStart and editEnd?
384 if (editRunning) {
385 brokenSwapFile = true;
386 m_document->editEnd();
387 }
388
389 // warn the user if the swap file is not complete
390 if (brokenSwapFile) {
391 qCWarning(LOG_KTE) << "Some data might be lost";
392 } else {
393 // set sane final cursor, if possible
394 KTextEditor::View *view = m_document->activeView();
395 redoCursor = m_document->undoManager()->lastRedoCursor();
396 if (view && redoCursor.isValid()) {
397 view->setCursorPosition(redoCursor);
398 }
399 }
400
401 // reconnect the signals
402 setTrackingEnabled(true);
403
404 return true;
405}
406
407void SwapFile::fileSaved(const QString &)
408{
409 m_needSync = false;
410
411 // remove old swap file (e.g. if a file A was "saved as" B)
412 removeSwapFile();
413
414 // set the name for the new swap file
415 updateFileName();
416}
417
418void SwapFile::startEditing()
419{
420 // no swap file, no work
421 if (m_swapfile.fileName().isEmpty()) {
422 return;
423 }
424
425 // if swap file doesn't exists, open it in WriteOnly mode
426 // if it does, append the data to the existing swap file,
427 // in case you recover and start editing again
428 if (!m_swapfile.exists()) {
429 // create path if not there
430 if (KateDocumentConfig::global()->swapFileMode() == KateDocumentConfig::SwapFilePresetDirectory
431 && !QDir(KateDocumentConfig::global()->swapDirectory()).exists()) {
432 QDir().mkpath(dirPath: KateDocumentConfig::global()->swapDirectory());
433 }
434
435 m_swapfile.open(flags: QIODevice::WriteOnly);
436 m_swapfile.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
437 m_stream.setDevice(&m_swapfile);
438
439 // write file header
440 m_stream << QByteArray(swapFileVersionString);
441
442 // write checksum
443 m_stream << m_document->checksum();
444 } else if (m_stream.device() == nullptr) {
445 m_swapfile.open(flags: QIODevice::Append);
446 m_swapfile.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
447 m_stream.setDevice(&m_swapfile);
448 }
449
450 // format: qint8
451 m_stream << EA_StartEditing;
452}
453
454void SwapFile::finishEditing()
455{
456 // skip if not open
457 if (!m_swapfile.isOpen()) {
458 return;
459 }
460
461 // write the file to the disk every 15 seconds (default)
462 // skip this if we disabled that
463 if (m_document->config()->swapSyncInterval() != 0 && !syncTimer()->isActive()) {
464 // important: we store the interval as seconds, start wants milliseconds!
465 syncTimer()->start(msec: m_document->config()->swapSyncInterval() * 1000);
466 }
467
468 // format: qint8
469 m_stream << EA_FinishEditing;
470 m_swapfile.flush();
471}
472
473void SwapFile::wrapLine(KTextEditor::Document *, const KTextEditor::Cursor position)
474{
475 // skip if not open
476 if (!m_swapfile.isOpen()) {
477 return;
478 }
479
480 // format: qint8, int, int
481 m_stream << EA_WrapLine << position.line() << position.column();
482
483 m_needSync = true;
484}
485
486void SwapFile::unwrapLine(KTextEditor::Document *, int line)
487{
488 // skip if not open
489 if (!m_swapfile.isOpen()) {
490 return;
491 }
492
493 // format: qint8, int
494 m_stream << EA_UnwrapLine << line;
495
496 m_needSync = true;
497}
498
499void SwapFile::insertText(KTextEditor::Document *, const KTextEditor::Cursor position, const QString &text)
500{
501 // skip if not open
502 if (!m_swapfile.isOpen()) {
503 return;
504 }
505
506 // format: qint8, int, int, bytearray
507 m_stream << EA_InsertText << position.line() << position.column() << text.toUtf8();
508
509 m_needSync = true;
510}
511
512void SwapFile::removeText(KTextEditor::Document *, KTextEditor::Range range, const QString &)
513{
514 // skip if not open
515 if (!m_swapfile.isOpen()) {
516 return;
517 }
518
519 // format: qint8, int, int, int
520 Q_ASSERT(range.start().line() == range.end().line());
521 m_stream << EA_RemoveText << range.start().line() << range.start().column() << range.end().column();
522
523 m_needSync = true;
524}
525
526bool SwapFile::shouldRecover() const
527{
528 // should not recover if the file has already recovered in another view
529 if (m_recovered) {
530 return false;
531 }
532
533 return !m_swapfile.fileName().isEmpty() && m_swapfile.exists() && m_stream.device() == nullptr;
534}
535
536void SwapFile::discard()
537{
538 m_document->setReadWrite(true);
539 removeSwapFile();
540
541 // discard can also be called through the KTE::RecoveryInterface.
542 // Make sure, the message is hidden in this case as well.
543 if (m_swapMessage) {
544 m_swapMessage->deleteLater();
545 }
546}
547
548void SwapFile::removeSwapFile()
549{
550 if (!m_swapfile.fileName().isEmpty() && m_swapfile.exists()) {
551 m_stream.setDevice(nullptr);
552 m_swapfile.close();
553 m_swapfile.remove();
554 }
555}
556
557bool SwapFile::updateFileName()
558{
559 // first clear filename
560 m_swapfile.setFileName(QString());
561
562 // get the new path
563 QString path = fileName();
564 if (path.isNull()) {
565 return false;
566 }
567
568 m_swapfile.setFileName(path);
569 return true;
570}
571
572QString SwapFile::fileName()
573{
574 const QUrl &url = m_document->url();
575 if (url.isEmpty() || !url.isLocalFile()) {
576 return QString();
577 }
578
579 const QString fullLocalPath(url.toLocalFile());
580 QString path;
581 if (KateDocumentConfig::global()->swapFileMode() == KateDocumentConfig::SwapFilePresetDirectory) {
582 path = KateDocumentConfig::global()->swapDirectory();
583 path.append(c: QLatin1Char('/'));
584
585 // append the sha1 sum of the full path + filename, to avoid "too long" paths created
586 path.append(s: QString::fromLatin1(ba: QCryptographicHash::hash(data: fullLocalPath.toUtf8(), method: QCryptographicHash::Sha1).toHex()));
587 path.append(c: QLatin1Char('-'));
588 path.append(s: QFileInfo(fullLocalPath).fileName());
589
590 path.append(s: QLatin1String(".kate-swp"));
591 } else {
592 path = fullLocalPath;
593 int poz = path.lastIndexOf(c: QLatin1Char('/'));
594 path.insert(i: poz + 1, c: QLatin1Char('.'));
595 path.append(s: QLatin1String(".kate-swp"));
596 }
597
598 return path;
599}
600
601QTimer *SwapFile::syncTimer()
602{
603 if (s_timer == nullptr) {
604 s_timer = new QTimer(QApplication::instance());
605 s_timer->setSingleShot(true);
606 }
607
608 return s_timer;
609}
610
611void SwapFile::writeFileToDisk()
612{
613 if (m_needSync) {
614 m_needSync = false;
615
616#ifndef Q_OS_WIN
617 // ensure that the file is written to disk
618#if HAVE_FDATASYNC
619 fdatasync(fildes: m_swapfile.handle());
620#else
621 fsync(m_swapfile.handle());
622#endif
623#endif
624 }
625}
626
627void SwapFile::showSwapFileMessage()
628{
629 m_swapMessage = new KTextEditor::Message(i18n("The file was not closed properly."), KTextEditor::Message::Warning);
630 m_swapMessage->setWordWrap(true);
631
632 QAction *diffAction = new QAction(QIcon::fromTheme(QStringLiteral("split")), i18n("View Changes"), nullptr);
633 QAction *recoverAction = new QAction(QIcon::fromTheme(QStringLiteral("edit-redo")), i18n("Recover Data"), nullptr);
634 QAction *discardAction = new QAction(KStandardGuiItem::discard().icon(), i18n("Discard"), nullptr);
635
636 m_swapMessage->addAction(action: diffAction, closeOnTrigger: false);
637 m_swapMessage->addAction(action: recoverAction);
638 m_swapMessage->addAction(action: discardAction);
639
640 connect(sender: diffAction, signal: &QAction::triggered, context: this, slot: &SwapFile::showDiff);
641 connect(sender: recoverAction, signal: &QAction::triggered, context: this, slot: qOverload<>(&Kate::SwapFile::recover), type: Qt::QueuedConnection);
642 connect(sender: discardAction, signal: &QAction::triggered, context: this, slot: &SwapFile::discard, type: Qt::QueuedConnection);
643
644 m_document->postMessage(message: m_swapMessage);
645}
646
647void SwapFile::showDiff()
648{
649 // the diff creator deletes itself through deleteLater() when it's done
650 SwapDiffCreator *diffCreator = new SwapDiffCreator(this);
651 diffCreator->viewDiff();
652}
653
654}
655

source code of ktexteditor/src/swapfile/kateswapfile.cpp