File indexing completed on 2024-03-24 05:49:20
0001 // SPDX-FileCopyrightText: 2020 Simon Persson <simon.persson@mykolab.com> 0002 // 0003 // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL 0004 0005 #include "restoredialog.h" 0006 #include "ui_restoredialog.h" 0007 #include "restorejob.h" 0008 #include "dirselector.h" 0009 #include "kuputils.h" 0010 #include "kupfiledigger_debug.h" 0011 0012 #include <KFileUtils> 0013 #include <KFileWidget> 0014 #include <KIO/CopyJob> 0015 #include <KIO/JobUiDelegate> 0016 #include <KIO/JobUiDelegateFactory> 0017 #include <KIO/ListJob> 0018 #include <KIO/OpenUrlJob> 0019 #include <KIO/UDSEntry> 0020 #include <KLocalizedString> 0021 #include <KMessageBox> 0022 #include <KMessageWidget> 0023 #include <KProcess> 0024 #include <KWidgetJobTracker> 0025 #include <QStorageInfo> 0026 0027 #include <QDir> 0028 #include <QInputDialog> 0029 #include <QPushButton> 0030 #include <QTimer> 0031 #include <utility> 0032 #include <kio_version.h> 0033 0034 static const QString cKupTempRestoreFolder = QStringLiteral("_kup_temporary_restore_folder_"); 0035 0036 RestoreDialog::RestoreDialog(BupSourceInfo pPathInfo, QWidget *parent) 0037 : QDialog(parent), mUI(new Ui::RestoreDialog), mSourceInfo(std::move(pPathInfo)) 0038 { 0039 mSourceFileName = mSourceInfo.mPathInRepo.section(QDir::separator(), -1); 0040 0041 qCDebug(KUPFILEDIGGER) << "Starting restore dialog for repo: " << mSourceInfo.mRepoPath 0042 << ", restoring: " << mSourceInfo.mPathInRepo; 0043 0044 mUI->setupUi(this); 0045 0046 mFileWidget = nullptr; 0047 mDirSelector = nullptr; 0048 mJobTracker = nullptr; 0049 0050 mUI->mRestoreOriginalButton->setMinimumHeight(mUI->mRestoreOriginalButton->sizeHint().height() * 2); 0051 mUI->mRestoreCustomButton->setMinimumHeight(mUI->mRestoreCustomButton->sizeHint().height() * 2); 0052 0053 connect(mUI->mRestoreOriginalButton, SIGNAL(clicked()), SLOT(setOriginalDestination())); 0054 connect(mUI->mRestoreCustomButton, SIGNAL(clicked()), SLOT(setCustomDestination())); 0055 0056 mMessageWidget = new KMessageWidget(this); 0057 mMessageWidget->setWordWrap(true); 0058 mUI->mTopLevelVLayout->insertWidget(0, mMessageWidget); 0059 mMessageWidget->hide(); 0060 connect(mUI->mDestBackButton, SIGNAL(clicked()), mMessageWidget, SLOT(hide())); 0061 connect(mUI->mDestNextButton, SIGNAL(clicked()), SLOT(checkDestinationSelection())); 0062 connect(mUI->mDestBackButton, &QPushButton::clicked, this, [this]{mUI->mStackedWidget->setCurrentIndex(0);}); 0063 connect(mUI->mOverwriteBackButton, &QPushButton::clicked, this, [this]{mUI->mStackedWidget->setCurrentIndex(0);}); 0064 connect(mUI->mConfirmButton, SIGNAL(clicked()), SLOT(fileOverwriteConfirmed())); 0065 connect(mUI->mOpenDestinationButton, SIGNAL(clicked()), SLOT(openDestinationFolder())); 0066 } 0067 0068 RestoreDialog::~RestoreDialog() { 0069 delete mUI; 0070 } 0071 0072 void RestoreDialog::changeEvent(QEvent *pEvent) { 0073 QDialog::changeEvent(pEvent); 0074 switch (pEvent->type()) { 0075 case QEvent::LanguageChange: 0076 mUI->retranslateUi(this); 0077 break; 0078 default: 0079 break; 0080 } 0081 } 0082 0083 void RestoreDialog::setOriginalDestination() { 0084 if(mSourceInfo.mIsDirectory) { 0085 // the path in repo could have had slashes appended below, we are back here because user clicked "back" 0086 ensureNoTrailingSlash(mSourceInfo.mPathInRepo); 0087 //select parent of folder to be restored 0088 mDestination.setFile(mSourceInfo.mPathInRepo.section(QDir::separator(), 0, -2)); 0089 } else { 0090 mDestination.setFile(mSourceInfo.mPathInRepo); 0091 } 0092 startPrechecks(); 0093 } 0094 0095 void RestoreDialog::setCustomDestination() { 0096 if(mSourceInfo.mIsDirectory && mDirSelector == nullptr) { 0097 mDirSelector = new DirSelector(this); 0098 mDirSelector->setRootUrl(QUrl::fromLocalFile(QStringLiteral("/"))); 0099 QString lDirPath = mSourceInfo.mPathInRepo.section(QDir::separator(), 0, -2); 0100 mDirSelector->expandToUrl(QUrl::fromLocalFile(lDirPath)); 0101 mUI->mDestinationVLayout->insertWidget(0, mDirSelector); 0102 0103 auto lNewFolderButton = new QPushButton(QIcon::fromTheme(QStringLiteral("folder-new")), 0104 xi18nc("@action:button","New Folder...")); 0105 connect(lNewFolderButton, SIGNAL(clicked()), SLOT(createNewFolder())); 0106 mUI->mDestinationHLayout->insertWidget(0, lNewFolderButton); 0107 } else if(!mSourceInfo.mIsDirectory && mFileWidget == nullptr) { 0108 QFileInfo lFileInfo(mSourceInfo.mPathInRepo); 0109 do { 0110 lFileInfo.setFile(lFileInfo.absolutePath()); // check the file's directory first, not the file. 0111 } while(!lFileInfo.exists()); 0112 QUrl lStartSelection = QUrl::fromLocalFile(lFileInfo.absoluteFilePath() + '/' + mSourceFileName); 0113 mFileWidget = new KFileWidget(lStartSelection, this); 0114 mFileWidget->setOperationMode(KFileWidget::Saving); 0115 mFileWidget->setMode(KFile::File | KFile::LocalOnly); 0116 mUI->mDestinationVLayout->insertWidget(0, mFileWidget); 0117 } 0118 mUI->mDestNextButton->setFocus(); 0119 mUI->mStackedWidget->setCurrentIndex(1); 0120 } 0121 0122 void RestoreDialog::checkDestinationSelection() { 0123 if(mSourceInfo.mIsDirectory) { 0124 QUrl lUrl = mDirSelector->url(); 0125 if(!lUrl.isEmpty()) { 0126 mDestination.setFile(lUrl.path()); 0127 startPrechecks(); 0128 } else { 0129 mMessageWidget->setText(xi18nc("@info message bar appearing on top", 0130 "No destination was selected, please select one.")); 0131 mMessageWidget->setMessageType(KMessageWidget::Error); 0132 mMessageWidget->animatedShow(); 0133 } 0134 } else { 0135 connect(mFileWidget, SIGNAL(accepted()), SLOT(checkDestinationSelection2())); 0136 mFileWidget->slotOk(); // will emit accepted() if selection is valid, continue below then 0137 } 0138 } 0139 0140 void RestoreDialog::checkDestinationSelection2() { 0141 mFileWidget->accept(); // This call is needed for selectedFile() to return something. 0142 0143 QString lFilePath = mFileWidget->selectedFile(); 0144 if(!lFilePath.isEmpty()) { 0145 mDestination.setFile(lFilePath); 0146 startPrechecks(); 0147 } else { 0148 mMessageWidget->setText(xi18nc("@info message bar appearing on top", 0149 "No destination was selected, please select one.")); 0150 mMessageWidget->setMessageType(KMessageWidget::Error); 0151 mMessageWidget->animatedShow(); 0152 } 0153 } 0154 0155 void RestoreDialog::startPrechecks() { 0156 mUI->mFileConflictList->clear(); 0157 mSourceSize = 0; 0158 mFileSizes.clear(); 0159 0160 qCDebug(KUPFILEDIGGER) << "Destination has been selected: " << mDestination.absoluteFilePath(); 0161 0162 if(mSourceInfo.mIsDirectory) { 0163 mDirectoriesCount = 1; // the folder being restored, rest will be added during listing. 0164 mRestorationPath = mDestination.absoluteFilePath(); 0165 mFolderToCreate = QFileInfo(mDestination.absoluteFilePath() + QDir::separator() + mSourceFileName); 0166 mSavedWorkingDirectory.clear(); 0167 if(mFolderToCreate.exists()) { 0168 if(mFolderToCreate.isDir()) { 0169 // destination dir exists, first restore to a subfolder, then move files up. 0170 mRestorationPath = mFolderToCreate.absoluteFilePath(); 0171 QDir lDir(mFolderToCreate.absoluteFilePath()); 0172 lDir.setFilter(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot); 0173 if(lDir.count() > 0) { // destination dir exists and is non-empty. 0174 mRestorationPath.append(QDir::separator()); 0175 mRestorationPath.append(cKupTempRestoreFolder); 0176 } 0177 // make bup not restore the source folder itself but instead it's contents 0178 mSourceInfo.mPathInRepo.append(QDir::separator()); 0179 // folder already exists, need to check for files about to be overwritten. 0180 // will create QFileInfos with relative paths during listing and compare with listed source entries. 0181 mSavedWorkingDirectory = QDir::currentPath(); 0182 QDir::setCurrent(mFolderToCreate.absoluteFilePath()); 0183 } else { 0184 mUI->mFileConflictList->addItem(mFolderToCreate.absoluteFilePath()); 0185 mRestorationPath.append(QDir::separator()); 0186 mRestorationPath.append(cKupTempRestoreFolder); 0187 } 0188 } 0189 qCDebug(KUPFILEDIGGER) << "Starting source file listing job on: " << mSourceInfo.mBupKioPath; 0190 KIO::ListJob *lListJob = KIO::listRecursive(mSourceInfo.mBupKioPath, KIO::HideProgressInfo); 0191 auto lJobTracker = new KWidgetJobTracker(this); 0192 lJobTracker->registerJob(lListJob); 0193 QWidget *lProgressWidget = lJobTracker->widget(lListJob); 0194 mUI->mSourceScanLayout->insertWidget(2, lProgressWidget); 0195 lProgressWidget->show(); 0196 connect(lListJob, SIGNAL(entries(KIO::Job*,KIO::UDSEntryList)), 0197 SLOT(collectSourceListing(KIO::Job*,KIO::UDSEntryList))); 0198 connect(lListJob, SIGNAL(result(KJob*)), SLOT(sourceListingCompleted(KJob*))); 0199 lListJob->start(); 0200 mUI->mStackedWidget->setCurrentIndex(4); 0201 } else { 0202 mDirectoriesCount = 0; 0203 mSourceSize = mSourceInfo.mSize; 0204 mFileSizes.insert(mSourceFileName, mSourceInfo.mSize); 0205 mRestorationPath = mDestination.absolutePath(); 0206 if(mDestination.exists() || mDestination.fileName() != mSourceFileName) { 0207 mRestorationPath.append(QDir::separator()); 0208 mRestorationPath.append(cKupTempRestoreFolder); 0209 if(mDestination.exists()) { 0210 mUI->mFileConflictList->addItem(mDestination.absoluteFilePath()); 0211 } 0212 } 0213 completePrechecks(); 0214 } 0215 } 0216 0217 void RestoreDialog::collectSourceListing(KIO::Job *pJob, const KIO::UDSEntryList &pEntryList) { 0218 Q_UNUSED(pJob) 0219 KIO::UDSEntryList::ConstIterator it = pEntryList.begin(); 0220 const KIO::UDSEntryList::ConstIterator end = pEntryList.end(); 0221 for(; it != end; ++it) { 0222 QString lEntryName = it->stringValue(KIO::UDSEntry::UDS_NAME); 0223 if(it->isDir()) { 0224 if(lEntryName != QStringLiteral(".") && lEntryName != QStringLiteral("..")) { 0225 mDirectoriesCount++; 0226 } 0227 } else { 0228 if(!it->isLink()) { 0229 auto lEntrySize = it->numberValue(KIO::UDSEntry::UDS_SIZE); 0230 mSourceSize += lEntrySize; 0231 mFileSizes.insert(mSourceFileName + QDir::separator() + lEntryName, lEntrySize); 0232 } 0233 if(!mSavedWorkingDirectory.isEmpty()) { 0234 if(QFileInfo::exists(lEntryName)) { 0235 mUI->mFileConflictList->addItem(lEntryName); 0236 } 0237 } 0238 } 0239 } 0240 } 0241 0242 void RestoreDialog::sourceListingCompleted(KJob *pJob) { 0243 qCDebug(KUPFILEDIGGER) << "Source listing job completed. Exit status: " << pJob->error(); 0244 if(!mSavedWorkingDirectory.isEmpty()) { 0245 QDir::setCurrent(mSavedWorkingDirectory); 0246 } 0247 if(pJob->error() != 0) { 0248 mMessageWidget->setText(xi18nc("@info message bar appearing on top", 0249 "There was a problem while getting a list of all files to restore: %1", 0250 pJob->errorString())); 0251 mMessageWidget->setMessageType(KMessageWidget::Error); 0252 mMessageWidget->animatedShow(); 0253 mUI->mStackedWidget->setCurrentIndex(0); 0254 } else { 0255 completePrechecks(); 0256 } 0257 } 0258 0259 void RestoreDialog::completePrechecks() { 0260 qCDebug(KUPFILEDIGGER) << "Starting free disk space check on: " << mDestination.absolutePath(); 0261 QStorageInfo storageInfo(mDestination.absolutePath()); 0262 if(storageInfo.isValid() && storageInfo.bytesAvailable() < mSourceSize) { 0263 mMessageWidget->setText(xi18nc("@info message bar appearing on top", 0264 "The destination does not have enough space available. " 0265 "Please choose a different destination or free some space.")); 0266 mMessageWidget->setMessageType(KMessageWidget::Error); 0267 mMessageWidget->animatedShow(); 0268 mUI->mStackedWidget->setCurrentIndex(0); 0269 } else if(mUI->mFileConflictList->count() > 0) { 0270 qCDebug(KUPFILEDIGGER) << "Detected file conflicts."; 0271 if(mSourceInfo.mIsDirectory) { 0272 QString lDateString = QLocale().toString(QDateTime::fromSecsSinceEpoch(mSourceInfo.mCommitTime).toLocalTime()); 0273 lDateString.replace(QLatin1Char('/'), QLatin1Char('-')); // make sure no slashes in suggested folder name 0274 mUI->mNewFolderNameEdit->setText(mSourceFileName + 0275 xi18nc("added to the suggested filename when restoring, %1 is the time when backup was saved", 0276 " - saved at %1", lDateString)); 0277 mUI->mConflictTitleLabel->setText(xi18nc("@info", "Folder already exists, please choose a solution")); 0278 } else { 0279 mUI->mOverwriteRadioButton->setChecked(true); 0280 mUI->mOverwriteRadioButton->hide(); 0281 mUI->mNewNameRadioButton->hide(); 0282 mUI->mNewFolderNameEdit->hide(); 0283 mUI->mConflictTitleLabel->setText(xi18nc("@info", "File already exists")); 0284 } 0285 mUI->mStackedWidget->setCurrentIndex(2); 0286 } else { 0287 startRestoring(); 0288 } 0289 } 0290 0291 void RestoreDialog::fileOverwriteConfirmed() { 0292 if(mSourceInfo.mIsDirectory && mUI->mNewNameRadioButton->isChecked()) { 0293 QFileInfo lNewFolderInfo(mDestination.absoluteFilePath() + QDir::separator() + mUI->mNewFolderNameEdit->text()); 0294 if(lNewFolderInfo.exists()) { 0295 mMessageWidget->setText(xi18nc("@info message bar appearing on top", 0296 "The new name entered already exists, please enter a different one.")); 0297 mMessageWidget->setMessageType(KMessageWidget::Error); 0298 mMessageWidget->animatedShow(); 0299 return; 0300 } 0301 mFolderToCreate = QFileInfo(mDestination.absoluteFilePath() + QDir::separator() + mUI->mNewFolderNameEdit->text()); 0302 mRestorationPath = mFolderToCreate.absoluteFilePath(); 0303 if(!mSourceInfo.mPathInRepo.endsWith(QDir::separator())) { 0304 mSourceInfo.mPathInRepo.append(QDir::separator()); 0305 } 0306 } 0307 startRestoring(); 0308 } 0309 0310 void RestoreDialog::startRestoring() { 0311 QString lSourcePath(QDir::separator()); 0312 lSourcePath.append(mSourceInfo.mBranchName); 0313 lSourcePath.append(QDir::separator()); 0314 QDateTime lCommitTime = QDateTime::fromSecsSinceEpoch(mSourceInfo.mCommitTime); 0315 lSourcePath.append(lCommitTime.toString(QStringLiteral("yyyy-MM-dd-hhmmss"))); 0316 lSourcePath.append(mSourceInfo.mPathInRepo); 0317 qCDebug(KUPFILEDIGGER) << "Starting restore. Source path: " << lSourcePath << ", restore path: " << mRestorationPath; 0318 auto lRestoreJob = new RestoreJob(mSourceInfo.mRepoPath, lSourcePath, mRestorationPath, 0319 mDirectoriesCount, mSourceSize, mFileSizes); 0320 if(mJobTracker == nullptr) { 0321 mJobTracker = new KWidgetJobTracker(this); 0322 } 0323 mJobTracker->registerJob(lRestoreJob); 0324 QWidget *lProgressWidget = mJobTracker->widget(lRestoreJob); 0325 mUI->mRestoreProgressLayout->insertWidget(2, lProgressWidget); 0326 lProgressWidget->show(); 0327 connect(lRestoreJob, SIGNAL(result(KJob*)), SLOT(restoringCompleted(KJob*))); 0328 lRestoreJob->start(); 0329 mUI->mCloseButton->hide(); 0330 mUI->mStackedWidget->setCurrentIndex(3); 0331 } 0332 0333 void RestoreDialog::restoringCompleted(KJob *pJob) { 0334 qCDebug(KUPFILEDIGGER) << "Restore job completed. Exit status: " << pJob->error(); 0335 if(pJob->error() != 0) { 0336 mUI->mRestorationOutput->setPlainText(pJob->errorText()); 0337 mUI->mRestorationStackWidget->setCurrentIndex(1); 0338 mUI->mCloseButton->show(); 0339 } else { 0340 if(!mSourceInfo.mIsDirectory && mSourceFileName != mDestination.fileName()) { 0341 QUrl lSourceUrl = QUrl::fromLocalFile(mRestorationPath + '/' + mSourceFileName); 0342 QUrl lDestinationUrl = QUrl::fromLocalFile(mRestorationPath + '/' + mDestination.fileName()); 0343 KIO::CopyJob *lFileMoveJob = KIO::move(lSourceUrl, lDestinationUrl, KIO::HideProgressInfo); 0344 connect(lFileMoveJob, SIGNAL(result(KJob*)), SLOT(fileMoveCompleted(KJob*))); 0345 qCDebug(KUPFILEDIGGER) << "Starting file move job from: " << lSourceUrl << ", to: " << lDestinationUrl; 0346 lFileMoveJob->start(); 0347 } else { 0348 moveFolder(); 0349 } 0350 } 0351 } 0352 0353 void RestoreDialog::fileMoveCompleted(KJob *pJob) { 0354 qCDebug(KUPFILEDIGGER) << "File move job completed. Exit status: " << pJob->error(); 0355 if(pJob->error() != 0) { 0356 mUI->mRestorationOutput->setPlainText(pJob->errorText()); 0357 mUI->mRestorationStackWidget->setCurrentIndex(1); 0358 } else { 0359 moveFolder(); 0360 } 0361 } 0362 0363 void RestoreDialog::createNewFolder() { 0364 bool lUserAccepted; 0365 QUrl lUrl = mDirSelector->url(); 0366 QString lNameSuggestion = xi18nc("default folder name when creating a new folder", "New Folder"); 0367 if(QFileInfo::exists(lUrl.adjusted(QUrl::StripTrailingSlash).path() + '/' + lNameSuggestion)) { 0368 lNameSuggestion = KFileUtils::suggestName(lUrl, lNameSuggestion); 0369 } 0370 0371 QString lSelectedName = QInputDialog::getText(this, xi18nc("@title:window", "New Folder" ), 0372 xi18nc("@label:textbox", "Create new folder in:\n%1", lUrl.path()), 0373 QLineEdit::Normal, lNameSuggestion, &lUserAccepted); 0374 0375 if (!lUserAccepted) 0376 return; 0377 0378 QUrl lPartialUrl(lUrl); 0379 const QStringList lDirectories = lSelectedName.split(QDir::separator(), Qt::SkipEmptyParts); 0380 foreach(QString lSubDirectory, lDirectories) { 0381 QDir lDir(lPartialUrl.path()); 0382 if(lDir.exists(lSubDirectory)) { 0383 lPartialUrl = lPartialUrl.adjusted(QUrl::StripTrailingSlash); 0384 lPartialUrl.setPath(lPartialUrl.path() + '/' + (lSubDirectory)); 0385 KMessageBox::error(this, i18n("A folder named %1 already exists.", lPartialUrl.path())); 0386 return; 0387 } 0388 if(!lDir.mkdir(lSubDirectory)) { 0389 lPartialUrl = lPartialUrl.adjusted(QUrl::StripTrailingSlash); 0390 lPartialUrl.setPath(lPartialUrl.path() + '/' + (lSubDirectory)); 0391 KMessageBox::error(this, i18n("You do not have permission to create %1.", lPartialUrl.path())); 0392 return; 0393 } 0394 lPartialUrl = lPartialUrl.adjusted(QUrl::StripTrailingSlash); 0395 lPartialUrl.setPath(lPartialUrl.path() + '/' + (lSubDirectory)); 0396 } 0397 mDirSelector->expandToUrl(lPartialUrl); 0398 } 0399 0400 void RestoreDialog::openDestinationFolder() { 0401 auto *job = new KIO::OpenUrlJob(QUrl::fromLocalFile(mSourceInfo.mIsDirectory ? 0402 mFolderToCreate.absoluteFilePath() : 0403 mDestination.absolutePath())); 0404 #if KIO_VERSION > QT_VERSION_CHECK(5, 98, 0) 0405 auto *delegate = KIO::createDefaultJobUiDelegate(KIO::JobUiDelegate::AutoHandlingEnabled, this); 0406 #else 0407 auto *delegate = new KIO::JobUiDelegate(KIO::JobUiDelegate::AutoHandlingEnabled, this); 0408 #endif 0409 job->setUiDelegate(delegate); 0410 job->start(); 0411 } 0412 0413 void RestoreDialog::moveFolder() { 0414 if(!mRestorationPath.endsWith(cKupTempRestoreFolder)) { 0415 mUI->mRestorationStackWidget->setCurrentIndex(2); 0416 mUI->mCloseButton->show(); 0417 qCDebug(KUPFILEDIGGER) << "Overall restore operation completed."; 0418 return; 0419 } 0420 QUrl lSourceUrl = QUrl::fromLocalFile(mRestorationPath); 0421 QUrl lDestinationUrl = QUrl::fromLocalFile(mRestorationPath.section(QDir::separator(), 0, -2)); 0422 KIO::CopyJob *lFolderMoveJob = KIO::moveAs(lSourceUrl, lDestinationUrl, KIO::Overwrite | KIO::HideProgressInfo); 0423 connect(lFolderMoveJob, SIGNAL(result(KJob*)), SLOT(folderMoveCompleted(KJob*))); 0424 mJobTracker->registerJob(lFolderMoveJob); 0425 QWidget *lProgressWidget = mJobTracker->widget(lFolderMoveJob); 0426 mUI->mRestoreProgressLayout->insertWidget(1, lProgressWidget); 0427 lProgressWidget->show(); 0428 qCDebug(KUPFILEDIGGER) << "Starting folder move job from: " << lSourceUrl << ", to: " << lDestinationUrl; 0429 lFolderMoveJob->start(); 0430 } 0431 0432 void RestoreDialog::folderMoveCompleted(KJob *pJob) { 0433 qCDebug(KUPFILEDIGGER) << "Folder move job completed. Exit status: " << pJob->error(); 0434 mUI->mCloseButton->show(); 0435 if(pJob->error() != 0) { 0436 mUI->mRestorationOutput->setPlainText(pJob->errorText()); 0437 mUI->mRestorationStackWidget->setCurrentIndex(1); 0438 } else { 0439 qCDebug(KUPFILEDIGGER) << "Overall restore operation completed."; 0440 mUI->mRestorationStackWidget->setCurrentIndex(2); 0441 } 0442 }