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#else
32#include <io.h>
33#endif
34
35// swap file version header
36const static char swapFileVersionString[] = "Kate Swap File 2.0";
37
38// tokens for swap files
39const static qint8 EA_StartEditing = 'S';
40const static qint8 EA_FinishEditing = 'E';
41const static qint8 EA_WrapLine = 'W';
42const static qint8 EA_UnwrapLine = 'U';
43const static qint8 EA_InsertText = 'I';
44const static qint8 EA_RemoveText = 'R';
45
46namespace Kate
47{
48QTimer *SwapFile::s_timer = nullptr;
49
50SwapFile::SwapFile(KTextEditor::DocumentPrivate *document)
51 : QObject(document)
52 , m_document(document)
53 , m_trackingEnabled(false)
54 , m_recovered(false)
55 , m_needSync(false)
56{
57 // fixed version of serialisation
58 m_stream.setVersion(QDataStream::Qt_4_6);
59
60 // connect the timer
61 connect(sender: syncTimer(), signal: &QTimer::timeout, context: this, slot: &Kate::SwapFile::writeFileToDisk, type: Qt::DirectConnection);
62
63 // connecting the signals
64 connect(sender: &m_document->buffer(), signal: &KateBuffer::saved, context: this, slot: &Kate::SwapFile::fileSaved);
65 connect(sender: &m_document->buffer(), signal: &KateBuffer::loaded, context: this, slot: &Kate::SwapFile::fileLoaded);
66 connect(sender: m_document, signal: &KTextEditor::Document::configChanged, context: this, slot: &SwapFile::configChanged);
67
68 // tracking on!
69 setTrackingEnabled(true);
70}
71
72SwapFile::~SwapFile()
73{
74 // only remove swap file after data recovery (bug #304576)
75 if (!shouldRecover()) {
76 removeSwapFile();
77 }
78}
79
80void SwapFile::configChanged()
81{
82}
83
84void SwapFile::setTrackingEnabled(bool enable)
85{
86 if (m_trackingEnabled == enable) {
87 return;
88 }
89
90 m_trackingEnabled = enable;
91
92 if (m_trackingEnabled) {
93 connect(sender: m_document, signal: &KTextEditor::Document::editingStarted, context: this, slot: &Kate::SwapFile::startEditing);
94 connect(sender: m_document, signal: &KTextEditor::Document::editingFinished, context: this, slot: &Kate::SwapFile::finishEditing);
95 connect(sender: m_document, signal: &KTextEditor::DocumentPrivate::modifiedChanged, context: this, slot: &SwapFile::modifiedChanged);
96
97 connect(sender: m_document, signal: &KTextEditor::Document::lineWrapped, context: this, slot: &Kate::SwapFile::wrapLine);
98 connect(sender: m_document, signal: &KTextEditor::Document::lineUnwrapped, context: this, slot: &Kate::SwapFile::unwrapLine);
99 connect(sender: m_document, signal: &KTextEditor::Document::textInserted, context: this, slot: &Kate::SwapFile::insertText);
100 connect(sender: m_document, signal: &KTextEditor::Document::textRemoved, context: this, slot: &Kate::SwapFile::removeText);
101 } else {
102 disconnect(sender: m_document, signal: &KTextEditor::Document::editingStarted, receiver: this, slot: &Kate::SwapFile::startEditing);
103 disconnect(sender: m_document, signal: &KTextEditor::Document::editingFinished, receiver: this, slot: &Kate::SwapFile::finishEditing);
104 disconnect(sender: m_document, signal: &KTextEditor::DocumentPrivate::modifiedChanged, receiver: this, slot: &SwapFile::modifiedChanged);
105
106 disconnect(sender: m_document, signal: &KTextEditor::Document::lineWrapped, receiver: this, slot: &Kate::SwapFile::wrapLine);
107 disconnect(sender: m_document, signal: &KTextEditor::Document::lineUnwrapped, receiver: this, slot: &Kate::SwapFile::unwrapLine);
108 disconnect(sender: m_document, signal: &KTextEditor::Document::textInserted, receiver: this, slot: &Kate::SwapFile::insertText);
109 disconnect(sender: m_document, signal: &KTextEditor::Document::textRemoved, receiver: this, slot: &Kate::SwapFile::removeText);
110 }
111}
112
113void SwapFile::fileClosed()
114{
115 // remove old swap file, file is now closed
116 if (!shouldRecover()) {
117 removeSwapFile();
118 } else {
119 m_document->setReadWrite(true);
120 }
121
122 // purge filename
123 updateFileName();
124}
125
126KTextEditor::DocumentPrivate *SwapFile::document()
127{
128 return m_document;
129}
130
131bool SwapFile::isValidSwapFile(QDataStream &stream, bool checkDigest) const
132{
133 // read and check header
134 QByteArray header;
135 stream >> header;
136
137 if (header != swapFileVersionString) {
138 qCWarning(LOG_KTE) << "Can't open swap file, wrong version";
139 return false;
140 }
141
142 // read checksum
143 QByteArray checksum;
144 stream >> checksum;
145 // qCDebug(LOG_KTE) << "DIGEST:" << checksum << m_document->checksum();
146 if (checkDigest && checksum != m_document->checksum()) {
147 qCWarning(LOG_KTE) << "Can't recover from swap file, checksum of document has changed";
148 return false;
149 }
150
151 return true;
152}
153
154void SwapFile::fileLoaded(const QString &)
155{
156 // look for swap file
157 if (!updateFileName()) {
158 return;
159 }
160
161 if (!m_swapfile.exists()) {
162 // qCDebug(LOG_KTE) << "No swap file";
163 return;
164 }
165
166 if (!QFileInfo(m_swapfile).isReadable()) {
167 qCWarning(LOG_KTE) << "Can't open swap file (missing permissions)";
168 return;
169 }
170
171 // sanity check
172 QFile peekFile(fileName());
173 if (peekFile.open(flags: QIODevice::ReadOnly)) {
174 QDataStream stream(&peekFile);
175 if (!isValidSwapFile(stream, checkDigest: true)) {
176 removeSwapFile();
177 return;
178 }
179 peekFile.close();
180 } else {
181 qCWarning(LOG_KTE) << "Can't open swap file:" << fileName();
182 return;
183 }
184
185 // show swap file message
186 m_document->setReadWrite(false);
187 showSwapFileMessage();
188}
189
190void SwapFile::modifiedChanged()
191{
192 if (!m_document->isModified() && !shouldRecover()) {
193 // the file is not modified and we are not in recover mode
194 removeSwapFile();
195 }
196}
197
198void SwapFile::recover()
199{
200 m_document->setReadWrite(true);
201
202 // if isOpen() returns true, the swap file likely changed already (appended data)
203 // Example: The document was falsely marked as writable and the user changed
204 // text even though the recover bar was visible. In this case, a replay of
205 // the swap file across wrong document content would happen -> certainly wrong
206 if (m_swapfile.isOpen()) {
207 qCWarning(LOG_KTE) << "Attempt to recover an already modified document. Aborting";
208 removeSwapFile();
209 return;
210 }
211
212 // if the file doesn't exist, abort (user might have deleted it, or use two editor instances)
213 if (!m_swapfile.open(flags: QIODevice::ReadOnly)) {
214 qCWarning(LOG_KTE) << "Can't open swap file";
215 return;
216 }
217
218 // remember that the file has recovered
219 m_recovered = true;
220
221 // open data stream
222 m_stream.setDevice(&m_swapfile);
223
224 // replay the swap file
225 bool success = recover(m_stream);
226
227 // close swap file
228 m_stream.setDevice(nullptr);
229 m_swapfile.close();
230
231 if (!success) {
232 removeSwapFile();
233 }
234
235 // recover can also be called through the KTE::RecoveryInterface.
236 // Make sure, the message is hidden in this case as well.
237 if (m_swapMessage) {
238 m_swapMessage->deleteLater();
239 }
240}
241
242bool SwapFile::recover(QDataStream &stream, bool checkDigest)
243{
244 if (!isValidSwapFile(stream, checkDigest)) {
245 return false;
246 }
247
248 // disconnect current signals
249 setTrackingEnabled(false);
250
251 // needed to set undo/redo cursors in a sane way
252 bool firstEditInGroup = false;
253 KTextEditor::Cursor undoCursor = KTextEditor::Cursor::invalid();
254 KTextEditor::Cursor redoCursor = KTextEditor::Cursor::invalid();
255
256 // replay swapfile
257 bool editRunning = false;
258 bool brokenSwapFile = false;
259 while (!stream.atEnd()) {
260 if (brokenSwapFile) {
261 break;
262 }
263
264 qint8 type;
265 stream >> type;
266 switch (type) {
267 case EA_StartEditing: {
268 m_document->editStart();
269 editRunning = true;
270 firstEditInGroup = true;
271 undoCursor = KTextEditor::Cursor::invalid();
272 redoCursor = KTextEditor::Cursor::invalid();
273 break;
274 }
275 case EA_FinishEditing: {
276 m_document->editEnd();
277
278 // empty editStart() / editEnd() groups exist: only set cursor if required
279 if (!firstEditInGroup) {
280 // set undo/redo cursor of last KateUndoGroup of the undo manager
281 m_document->undoManager()->setUndoRedoCursorsOfLastGroup(undoCursor, redoCursor);
282 m_document->undoManager()->undoSafePoint();
283 }
284 firstEditInGroup = false;
285 editRunning = false;
286 break;
287 }
288 case EA_WrapLine: {
289 if (!editRunning) {
290 brokenSwapFile = true;
291 break;
292 }
293
294 int line = 0;
295 int column = 0;
296 stream >> line >> column;
297
298 // emulate buffer unwrapLine with document
299 m_document->editWrapLine(line, col: column, newLine: true);
300
301 // track undo/redo cursor
302 if (firstEditInGroup) {
303 firstEditInGroup = false;
304 undoCursor = KTextEditor::Cursor(line, column);
305 }
306 redoCursor = KTextEditor::Cursor(line + 1, 0);
307
308 break;
309 }
310 case EA_UnwrapLine: {
311 if (!editRunning) {
312 brokenSwapFile = true;
313 break;
314 }
315
316 int line = 0;
317 stream >> line;
318
319 // assert valid line
320 Q_ASSERT(line > 0);
321
322 const int undoColumn = m_document->lineLength(line: line - 1);
323
324 // emulate buffer unwrapLine with document
325 m_document->editUnWrapLine(line: line - 1, removeLine: true, length: 0);
326
327 // track undo/redo cursor
328 if (firstEditInGroup) {
329 firstEditInGroup = false;
330 undoCursor = KTextEditor::Cursor(line, 0);
331 }
332 redoCursor = KTextEditor::Cursor(line - 1, undoColumn);
333
334 break;
335 }
336 case EA_InsertText: {
337 if (!editRunning) {
338 brokenSwapFile = true;
339 break;
340 }
341
342 int line;
343 int column;
344 QByteArray text;
345 stream >> line >> column >> text;
346 QString textStr = QString::fromUtf8(utf8: text.data(), size: text.size());
347 m_document->insertText(position: KTextEditor::Cursor(line, column), s: textStr);
348
349 // track undo/redo cursor
350 if (firstEditInGroup) {
351 firstEditInGroup = false;
352 undoCursor = KTextEditor::Cursor(line, column);
353 }
354 redoCursor = KTextEditor::Cursor(line, column + textStr.length());
355
356 break;
357 }
358 case EA_RemoveText: {
359 if (!editRunning) {
360 brokenSwapFile = true;
361 break;
362 }
363
364 int line;
365 int startColumn;
366 int endColumn;
367 stream >> line >> startColumn >> endColumn;
368 m_document->removeText(range: KTextEditor::Range(KTextEditor::Cursor(line, startColumn), KTextEditor::Cursor(line, endColumn)));
369
370 // track undo/redo cursor
371 if (firstEditInGroup) {
372 firstEditInGroup = false;
373 undoCursor = KTextEditor::Cursor(line, endColumn);
374 }
375 redoCursor = KTextEditor::Cursor(line, startColumn);
376
377 break;
378 }
379 default: {
380 qCWarning(LOG_KTE) << "Unknown type:" << type;
381 }
382 }
383 }
384
385 // balanced editStart and editEnd?
386 if (editRunning) {
387 brokenSwapFile = true;
388 m_document->editEnd();
389 }
390
391 // warn the user if the swap file is not complete
392 if (brokenSwapFile) {
393 qCWarning(LOG_KTE) << "Some data might be lost";
394 } else {
395 // set sane final cursor, if possible
396 KTextEditor::View *view = m_document->activeView();
397 redoCursor = m_document->undoManager()->lastRedoCursor();
398 if (view && redoCursor.isValid()) {
399 view->setCursorPosition(redoCursor);
400 }
401 }
402
403 // reconnect the signals
404 setTrackingEnabled(true);
405
406 return true;
407}
408
409void SwapFile::fileSaved(const QString &)
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 m_needSync = true;
453}
454
455void SwapFile::finishEditing()
456{
457 // skip if not open
458 if (!m_swapfile.isOpen()) {
459 return;
460 }
461
462 // write the file to the disk every 15 seconds (default)
463 // skip this if we disabled that
464 if (m_document->config()->swapSyncInterval() != 0 && !syncTimer()->isActive()) {
465 // important: we store the interval as seconds, start wants milliseconds!
466 syncTimer()->start(msec: m_document->config()->swapSyncInterval() * 1000);
467 }
468
469 // format: qint8
470 m_stream << EA_FinishEditing;
471 m_needSync = true;
472}
473
474void SwapFile::wrapLine(KTextEditor::Document *, const KTextEditor::Cursor position)
475{
476 // skip if not open
477 if (!m_swapfile.isOpen()) {
478 return;
479 }
480
481 // format: qint8, int, int
482 m_stream << EA_WrapLine << position.line() << position.column();
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 m_needSync = true;
496}
497
498void SwapFile::insertText(KTextEditor::Document *, const KTextEditor::Cursor position, const QString &text)
499{
500 // skip if not open
501 if (!m_swapfile.isOpen()) {
502 return;
503 }
504
505 // format: qint8, int, int, bytearray
506 m_stream << EA_InsertText << position.line() << position.column() << text.toUtf8();
507 m_needSync = true;
508}
509
510void SwapFile::removeText(KTextEditor::Document *, KTextEditor::Range range, const QString &)
511{
512 // skip if not open
513 if (!m_swapfile.isOpen()) {
514 return;
515 }
516
517 // format: qint8, int, int, int
518 Q_ASSERT(range.start().line() == range.end().line());
519 m_stream << EA_RemoveText << range.start().line() << range.start().column() << range.end().column();
520 m_needSync = true;
521}
522
523bool SwapFile::shouldRecover() const
524{
525 // should not recover if the file has already recovered in another view
526 if (m_recovered) {
527 return false;
528 }
529
530 return !m_swapfile.fileName().isEmpty() && m_swapfile.exists() && m_stream.device() == nullptr;
531}
532
533void SwapFile::discard()
534{
535 m_document->setReadWrite(true);
536 removeSwapFile();
537
538 // discard can also be called through the KTE::RecoveryInterface.
539 // Make sure, the message is hidden in this case as well.
540 if (m_swapMessage) {
541 m_swapMessage->deleteLater();
542 }
543}
544
545void SwapFile::removeSwapFile()
546{
547 // ensure we have no stray sync
548 m_needSync = false;
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 // ensure buffers are flushed first
617 m_swapfile.flush();
618
619#ifndef Q_OS_WIN
620 // ensure that the file is written to disk
621#if HAVE_FDATASYNC
622 fdatasync(fildes: m_swapfile.handle());
623#else
624 fsync(m_swapfile.handle());
625#endif
626#else
627 _commit(m_swapfile.handle());
628#endif
629 }
630}
631
632void SwapFile::showSwapFileMessage()
633{
634 m_swapMessage = new KTextEditor::Message(i18n("The file was not closed properly."), KTextEditor::Message::Warning);
635 m_swapMessage->setWordWrap(true);
636
637 QAction *diffAction = new QAction(QIcon::fromTheme(QStringLiteral("split")), i18n("View Changes"), nullptr);
638 QAction *recoverAction = new QAction(QIcon::fromTheme(QStringLiteral("edit-redo")), i18n("Recover Data"), nullptr);
639 QAction *discardAction = new QAction(KStandardGuiItem::discard().icon(), i18n("Discard"), nullptr);
640
641 m_swapMessage->addAction(action: diffAction, closeOnTrigger: false);
642 m_swapMessage->addAction(action: recoverAction);
643 m_swapMessage->addAction(action: discardAction);
644
645 connect(sender: diffAction, signal: &QAction::triggered, context: this, slot: &SwapFile::showDiff);
646 connect(sender: recoverAction, signal: &QAction::triggered, context: this, slot: qOverload<>(&Kate::SwapFile::recover), type: Qt::QueuedConnection);
647 connect(sender: discardAction, signal: &QAction::triggered, context: this, slot: &SwapFile::discard, type: Qt::QueuedConnection);
648
649 m_document->postMessage(message: m_swapMessage);
650}
651
652void SwapFile::showDiff()
653{
654 // the diff creator deletes itself through deleteLater() when it's done
655 SwapDiffCreator *diffCreator = new SwapDiffCreator(this);
656 diffCreator->viewDiff();
657}
658
659}
660

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