Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-Studio-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2022 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 :
23 : #include "openprojectscenario.h"
24 :
25 : #include <QFile>
26 : #include <QTimer>
27 : #include <QUrlQuery>
28 :
29 : #include "async/async.h"
30 : #include "translation.h"
31 :
32 : #include "cloud/clouderrors.h"
33 : #include "cloud/cloudtypes.h"
34 : #include "engraving/engravingerrors.h"
35 : #include "engraving/infrastructure/mscio.h"
36 :
37 : #include "notation/imasternotation.h"
38 : #include "notation/inotation.h"
39 :
40 : #include "inotationproject.h"
41 : #include "projecterrors.h"
42 : #include "types/projecturis.h"
43 :
44 : #include "log.h"
45 :
46 : using namespace mu::project;
47 : using namespace mu::notation;
48 : using namespace muse;
49 : using muse::async::Promise;
50 :
51 44 : bool OpenProjectScenario::isBusy(BusyStatus status) const
52 : {
53 44 : return m_busyStatuses.contains(status);
54 : }
55 :
56 71 : void OpenProjectScenario::setBusy(BusyStatus status, bool isBusy)
57 : {
58 71 : bool wasBusy = m_busyStatuses.contains(status);
59 71 : if (wasBusy == isBusy) {
60 0 : return;
61 : }
62 :
63 71 : if (isBusy) {
64 39 : m_busyStatuses.insert(status);
65 : } else {
66 32 : m_busyStatuses.erase(status);
67 : }
68 :
69 71 : m_busyChanged.notify();
70 : }
71 :
72 0 : muse::async::Notification OpenProjectScenario::busyChanged() const
73 : {
74 0 : return m_busyChanged;
75 : }
76 :
77 40 : Promise<Ret> OpenProjectScenario::runIfNotBusy(BusyStatus status, const std::function<Promise<Ret>()>& flow)
78 : {
79 40 : if (isBusy(status)) {
80 1 : return resolvedPromise(make_ret(Ret::Code::Busy));
81 : }
82 :
83 39 : setBusy(status, true);
84 :
85 78 : return flow().then<Ret>(this, [this, status](const Ret& ret, auto resolve) {
86 32 : setBusy(status, false);
87 32 : return resolve(ret);
88 39 : });
89 : }
90 :
91 2 : Promise<RetVal<Val> > OpenProjectScenario::openDialog(const UriQuery& query) const
92 : {
93 4 : return async::make_promise<RetVal<Val> >([this, query](auto resolve) {
94 2 : interactive()->open(query)
95 4 : .onResolve(this, [resolve](const Val& val) {
96 1 : (void)resolve(RetVal<Val>::make_ok(val));
97 : })
98 2 : .onReject(this, [resolve](int code, const std::string& err) {
99 1 : (void)resolve(RetVal<Val>(muse::make_ret(code, err)));
100 : });
101 :
102 2 : return Promise<RetVal<Val> >::dummy_result();
103 4 : });
104 : }
105 :
106 0 : static std::string cloudStatusCodeErrorMessage(const Ret& ret, bool withHelp = false)
107 : {
108 0 : std::string message;
109 :
110 0 : switch (ret.code()) {
111 0 : case int(cloud::Err::Status400_InvalidRequest):
112 : //: %1 will be replaced with the error code that MuseScore.com returned; this might contain english text
113 : //: that is deliberately not translated
114 0 : message = muse::qtrc("project/cloud", "MuseScore.com returned an error code: %1.")
115 0 : .arg("400 Invalid request").toStdString();
116 0 : break;
117 0 : case int(cloud::Err::Status401_AuthorizationRequired):
118 : //: %1 will be replaced with the error code that MuseScore.com returned; this might contain english text
119 : //: that is deliberately not translated
120 0 : message = muse::qtrc("project/cloud", "MuseScore.com returned an error code: %1.")
121 0 : .arg("401 Authorization required").toStdString();
122 0 : break;
123 0 : case int(cloud::Err::Status422_ValidationFailed):
124 : //: %1 will be replaced with the error code that MuseScore.com returned; this might contain english text
125 : //: that is deliberately not translated
126 0 : message = muse::qtrc("project/cloud", "MuseScore.com returned an error code: %1.")
127 0 : .arg("422 Validation failed").toStdString();
128 0 : break;
129 0 : case int(cloud::Err::Status429_RateLimitExceeded):
130 : //: %1 will be replaced with the error code that MuseScore.com returned; this might contain english text
131 : //: that is deliberately not translated
132 0 : message = muse::qtrc("project/cloud", "MuseScore.com returned an error code: %1.")
133 0 : .arg("429 Rate limit exceeded").toStdString();
134 0 : break;
135 0 : case int(cloud::Err::Status500_InternalServerError):
136 : //: %1 will be replaced with the error code that MuseScore.com returned; this might contain english text
137 : //: that is deliberately not translated
138 0 : message = muse::qtrc("project/cloud", "MuseScore.com returned an error code: %1.")
139 0 : .arg("500 Internal server error").toStdString();
140 0 : break;
141 0 : case int(cloud::Err::UnknownStatusCode): {
142 0 : if (const auto status = ret.data<int>("status", -1); status != -1) {
143 : //: %1 will be replaced with the error code that MuseScore.com returned, which is a number.
144 0 : message = muse::qtrc("project/cloud", "MuseScore.com returned an unknown error code: %1.")
145 0 : .arg(status).toStdString();
146 : } else {
147 0 : message = muse::trc("project/cloud", "MuseScore.com returned an unknown error code.");
148 : }
149 0 : } break;
150 : }
151 :
152 0 : if (withHelp) {
153 0 : message += "\n\n" + muse::trc("project/cloud", "Please try again later, or get help for this problem on MuseScore.com.");
154 : }
155 :
156 0 : return message;
157 0 : }
158 :
159 4 : void OpenProjectScenario::showCloudOpenError(const Ret& ret) const
160 : {
161 4 : std::string title = muse::trc("project", "Your score could not be opened");
162 4 : std::string message;
163 :
164 4 : switch (ret.code()) {
165 1 : case int(Err::InvalidCloudScoreId):
166 1 : message = muse::trc("project", "This score is invalid.");
167 1 : break;
168 0 : case int(Err::FileOpenError):
169 0 : message = muse::trc("project/cloud", "The file could not be downloaded to your disk.");
170 0 : break;
171 0 : case int(cloud::Err::Status403_AccountNotActivated):
172 0 : message = muse::trc("project/cloud", "Your MuseScore.com account needs to be verified first. "
173 0 : "Please activate your account via the link in the activation email.");
174 0 : break;
175 0 : case int(cloud::Err::Status403_NotOwner):
176 0 : message = muse::trc("project/cloud", "This score does not belong to this account. To access this score, make sure you are logged in "
177 0 : "to the desktop app with the account to which this score belongs.");
178 0 : break;
179 0 : case int(cloud::Err::Status404_NotFound):
180 0 : message = muse::trc("project/cloud", "The score could not be found, or cannot be accessed by your account.");
181 0 : break;
182 :
183 0 : case int(cloud::Err::Status400_InvalidRequest):
184 : case int(cloud::Err::Status401_AuthorizationRequired):
185 : case int(cloud::Err::Status422_ValidationFailed):
186 : case int(cloud::Err::Status429_RateLimitExceeded):
187 : case int(cloud::Err::Status500_InternalServerError):
188 : case int(cloud::Err::UnknownStatusCode):
189 0 : message = cloudStatusCodeErrorMessage(ret);
190 0 : break;
191 :
192 1 : case int(cloud::Err::NetworkError):
193 1 : message = muse::mtrc("project/cloud", "Could not connect to <a href=\"%1\">MuseScore.com</a>. "
194 : "Please check your internet connection or try again later.")
195 1 : .arg(u"https://musescore.com").toStdString();
196 1 : break;
197 2 : default:
198 2 : message = muse::trc("project/cloud", "Please try again later.");
199 2 : break;
200 : }
201 :
202 4 : interactive()->warning(title, message);
203 4 : }
204 :
205 5 : bool OpenProjectScenario::isUrlSupported(const QUrl& url) const
206 : {
207 5 : if (url.isLocalFile()) {
208 2 : return isFileSupported(muse::io::path_t(url));
209 : }
210 :
211 3 : if (url.scheme() == MUSESCORE_URL_SCHEME) {
212 2 : if (url.host() == OPEN_SCORE_URL_HOSTNAME) {
213 1 : return true;
214 : }
215 : }
216 :
217 2 : return false;
218 : }
219 :
220 5 : bool OpenProjectScenario::isFileSupported(const muse::io::path_t& path) const
221 : {
222 5 : std::string suffix = io::suffix(path);
223 5 : if (engraving::isMuseScoreFile(suffix)) {
224 2 : return true;
225 : }
226 :
227 3 : if (readers()->reader(suffix)) {
228 1 : return true;
229 : }
230 :
231 2 : return false;
232 5 : }
233 :
234 1 : Promise<Ret> OpenProjectScenario::openProject(const muse::rcommand::Params& params)
235 : {
236 1 : const QString url = params.at("url").toQString();
237 1 : const QString displayNameOverride = params.at("display_name").toQString();
238 :
239 2 : return openProject(ProjectFile(QUrl(url), displayNameOverride))
240 1 : .then<Ret>(this, [](const Ret& ret, auto resolve) {
241 1 : if (!ret) {
242 0 : LOGE() << ret.toString();
243 : }
244 :
245 1 : return resolve(ret);
246 2 : });
247 1 : }
248 :
249 10 : Promise<Ret> OpenProjectScenario::openProject(const ProjectFile& file)
250 : {
251 10 : LOGI() << "Try open project: url = " << file.url.toString() << ", displayNameOverride = " << file.displayNameOverride;
252 :
253 10 : if (file.isNull()) {
254 4 : return selectScoreOpeningFile().then<Ret>(this, [this](const io::path_t& askedPath, auto resolve) {
255 2 : if (askedPath.empty()) {
256 1 : return resolve(make_ret(Ret::Code::Cancel));
257 : }
258 :
259 1 : configuration()->setLastOpenedProjectsPath(io::dirpath(askedPath));
260 :
261 1 : openProject(askedPath).onResolve(this, [resolve](const Ret& ret) {
262 1 : (void)resolve(ret);
263 : });
264 :
265 1 : return Promise<Ret>::dummy_result();
266 2 : });
267 : }
268 :
269 8 : if (file.url.isLocalFile()) {
270 2 : return openProject(file.path(), file.displayNameOverride);
271 : }
272 :
273 6 : if (file.url.scheme() == MUSESCORE_URL_SCHEME) {
274 5 : return openMuseScoreUrl(file.url);
275 : }
276 :
277 1 : return resolvedPromise(make_ret(Err::UnsupportedUrl));
278 : }
279 :
280 24 : Promise<Ret> OpenProjectScenario::openProject(const muse::io::path_t& givenPath, const QString& displayNameOverride)
281 : {
282 48 : return runIfNotBusy(BusyStatus::Opening, [this, givenPath, displayNameOverride]() -> Promise<Ret> {
283 : //! Step 1. Take absolute path
284 23 : muse::io::path_t actualPath = fileSystem()->absoluteFilePath(givenPath);
285 23 : if (actualPath.empty()) {
286 : // We assume that a valid path has been specified to this method
287 1 : return resolvedPromise(make_ret(Ret::Code::UnknownError));
288 : }
289 :
290 : //! Step 2. If the project is already open in the current window, then just switch to showing the notation
291 22 : if (isProjectOpened(actualPath)) {
292 1 : return resolvedPromise(finishOpening());
293 : }
294 :
295 : //! Step 3. Check, if the project already opened in another window, then activate the window with the project
296 21 : if (multiwindowsProvider()->isProjectAlreadyOpened(actualPath)) {
297 1 : multiwindowsProvider()->activateWindowWithProject(actualPath);
298 1 : return resolvedPromise(make_ok());
299 : }
300 :
301 : //! Step 4. Check, if a any project is already open in the current window,
302 : //! then create a new instance
303 20 : if (projectSession()->project()) {
304 3 : QStringList args;
305 3 : args << actualPath.toQString();
306 :
307 3 : if (!displayNameOverride.isEmpty()) {
308 2 : args << "--score-display-name-override" << displayNameOverride;
309 : }
310 :
311 3 : multiwindowsProvider()->openNewWindow(args);
312 3 : return resolvedPromise(make_ok());
313 3 : }
314 :
315 : //! Step 5. If it's a cloud project, download the latest version
316 17 : if (configuration()->isCloudProject(actualPath) && !configuration()->isLegacyCloudProject(actualPath)) {
317 6 : return museScoreComService()->authorization()->checkCloudIsAvailable()
318 6 : .then<Ret>(this, [this, actualPath, displayNameOverride](const Ret& isCloudAvailable, auto resolve) {
319 3 : if (isCloudAvailable) {
320 2 : downloadAndOpenCloudProject(configuration()->cloudScoreIdFromPath(actualPath))
321 1 : .onResolve(this, [resolve](const Ret& ret) {
322 0 : (void)resolve(ret);
323 : });
324 :
325 1 : return Promise<Ret>::dummy_result();
326 : }
327 :
328 2 : if (fileSystem()->exists(actualPath)) {
329 1 : doOpenCloudProjectOffline(actualPath, displayNameOverride).onResolve(this, [resolve](const Ret& ret) {
330 1 : (void)resolve(ret);
331 : });
332 :
333 1 : return Promise<Ret>::dummy_result();
334 : }
335 :
336 1 : Ret ret = make_ret(cloud::Err::NetworkError);
337 1 : showCloudOpenError(ret);
338 1 : return resolve(ret);
339 4 : });
340 : }
341 :
342 : //! Step 6. Open project in the current window
343 14 : return doOpenProject(actualPath);
344 71 : });
345 : }
346 :
347 18 : Promise<RetVal<INotationProjectPtr> > OpenProjectScenario::loadProject(const muse::io::path_t& filePath)
348 : {
349 19 : TRACEFUNC;
350 :
351 18 : const auto project = projectCreator()->newProject(iocContext());
352 18 : IF_ASSERT_FAILED(project) {
353 0 : return resolvedPromise(RetVal<INotationProjectPtr>(make_ret(Ret::Code::InternalError)));
354 : }
355 :
356 18 : const bool hasUnsavedChanges = projectAutoSaver()->projectHasUnsavedChanges(filePath);
357 :
358 18 : const muse::io::path_t loadPath = hasUnsavedChanges ? projectAutoSaver()->projectAutoSavePath(filePath) : filePath;
359 18 : const std::string format = io::suffix(filePath);
360 :
361 18 : return loadWithFallback(project, loadPath, format)
362 36 : .then<RetVal<INotationProjectPtr> >(this, [this, project, filePath, hasUnsavedChanges](const Ret& result, auto resolve) {
363 18 : if (!result) {
364 8 : return resolve(RetVal<INotationProjectPtr>(result));
365 : }
366 :
367 10 : if (hasUnsavedChanges) {
368 : //! NOTE: redirect the project to the original file path
369 1 : project->setPath(filePath);
370 :
371 1 : project->markAsUnsaved();
372 : }
373 :
374 10 : if (projectAutoSaver()->isAutosaveOfNewlyCreatedProject(filePath)) {
375 1 : project->markAsNewlyCreated();
376 : }
377 :
378 10 : return resolve(RetVal<INotationProjectPtr>::make_ok(project));
379 18 : });
380 18 : }
381 :
382 18 : Promise<Ret> OpenProjectScenario::loadWithFallback(const std::shared_ptr<INotationProject>& project,
383 : const muse::io::path_t& loadPath,
384 : const std::string& format)
385 : {
386 18 : Ret result = project->load(loadPath, OpenParams(), format);
387 :
388 18 : if (result || result.code() == static_cast<int>(Ret::Code::Cancel)) {
389 11 : return resolvedPromise(result);
390 : }
391 :
392 14 : return shouldRetryLoadAfterError(result, loadPath)
393 14 : .then<Ret>(this, [project, loadPath, format, result](bool forceLoad, auto resolve) {
394 7 : if (!forceLoad) {
395 4 : return resolve(result);
396 : }
397 :
398 3 : OpenParams params;
399 3 : params.forceMode = forceLoad;
400 3 : return resolve(project->load(loadPath, params, format));
401 10 : });
402 18 : }
403 :
404 14 : Promise<Ret> OpenProjectScenario::doOpenProject(const muse::io::path_t& filePath)
405 : {
406 15 : TRACEFUNC;
407 :
408 28 : return loadProject(filePath).then<Ret>(this, [this, filePath](const RetVal<INotationProjectPtr>& rv, auto resolve) {
409 14 : if (!rv.ret) {
410 8 : return resolve(rv.ret);
411 : }
412 :
413 6 : INotationProjectPtr project = rv.val;
414 :
415 6 : bool isNewlyCreated = projectAutoSaver()->isAutosaveOfNewlyCreatedProject(filePath);
416 6 : if (!isNewlyCreated) {
417 5 : recentFilesController()->prependRecentFile(makeRecentFile(project));
418 : }
419 :
420 6 : projectSession()->setProject(project);
421 :
422 6 : return resolve(finishOpening());
423 34 : });
424 14 : }
425 :
426 3 : Promise<Ret> OpenProjectScenario::doOpenCloudProject(const muse::io::path_t& filePath, const CloudProjectInfo& info, bool isOwner)
427 : {
428 6 : return loadProject(filePath).then<Ret>(this, [this, filePath, info, isOwner](const RetVal<INotationProjectPtr>& rv, auto resolve) {
429 3 : if (!rv.ret) {
430 0 : return resolve(rv.ret);
431 : }
432 :
433 3 : INotationProjectPtr project = rv.val;
434 :
435 3 : if (isOwner) {
436 2 : project->setCloudInfo(info);
437 : } else {
438 1 : project->markAsNewlyCreated();
439 1 : project->setCloudInfo(CloudProjectInfo());
440 : }
441 :
442 3 : bool isNewlyCreated = projectAutoSaver()->isAutosaveOfNewlyCreatedProject(filePath)
443 3 : || !isOwner;
444 3 : if (!isNewlyCreated) {
445 2 : recentFilesController()->prependRecentFile(makeRecentFile(project));
446 : }
447 :
448 3 : projectSession()->setProject(project);
449 :
450 3 : return resolve(finishOpening());
451 9 : });
452 : }
453 :
454 1 : Promise<Ret> OpenProjectScenario::doOpenCloudProjectOffline(const muse::io::path_t& filePath, const QString& displayNameOverride)
455 : {
456 2 : return loadProject(filePath)
457 2 : .then<Ret>(this, [this, displayNameOverride](const RetVal<INotationProjectPtr>& rv, auto resolve) {
458 1 : if (!rv.ret) {
459 0 : return resolve(rv.ret);
460 : }
461 :
462 1 : INotationProjectPtr project = rv.val;
463 1 : CloudProjectInfo info = project->cloudInfo();
464 1 : info.name = displayNameOverride;
465 1 : project->setCloudInfo(info);
466 :
467 1 : recentFilesController()->prependRecentFile(makeRecentFile(project));
468 1 : projectSession()->setProject(project);
469 :
470 1 : return resolve(finishOpening());
471 3 : });
472 : }
473 :
474 11 : Ret OpenProjectScenario::finishOpening()
475 : {
476 11 : const Uri pageUri = resolveNotationPageUri();
477 :
478 : //! Show MuseSounds / MuseSampler update if need
479 11 : auto showUpdateNotification = [this]() {
480 11 : QTimer::singleShot(1000, [this]() {
481 0 : if (museSoundsCheckUpdateScenario()->hasUpdate()) {
482 0 : museSoundsCheckUpdateScenario()->showUpdate();
483 0 : } else if (!museSamplerCheckStatusScenario()->alreadyChecked()) {
484 0 : museSamplerCheckStatusScenario()->checkAndShowStatusIfNeed();
485 : }
486 0 : });
487 11 : };
488 :
489 11 : if (pageUri == NOTATION_PAGE_URI) {
490 11 : if (interactive()->isOpened(NOTATION_PAGE_URI).val) {
491 11 : showUpdateNotification();
492 : } else {
493 0 : async::Channel<Uri> opened = interactive()->opened();
494 0 : opened.onReceive(this, [this, opened, showUpdateNotification](const Uri&) {
495 0 : async::Async::call(this, [this, opened, showUpdateNotification]() {
496 0 : async::Channel<Uri> mut = opened;
497 0 : mut.disconnect(this);
498 :
499 0 : showUpdateNotification();
500 0 : });
501 0 : });
502 0 : }
503 : }
504 :
505 22 : return openPageIfNeed(pageUri);
506 11 : }
507 :
508 15 : Uri OpenProjectScenario::resolveNotationPageUri() const
509 : {
510 15 : INotationProjectPtr project = projectSession()->project();
511 15 : if (!project || !project->cloudInfo().isValid()) {
512 13 : return NOTATION_PAGE_URI;
513 : }
514 :
515 2 : int scoreId = static_cast<int>(muse::cloud::idFromCloudUrl(project->cloudInfo().sourceUrl).toUint64());
516 2 : if (convertFileToScoreScenario()->isAwaitingReview(scoreId)) {
517 1 : return NOTATION_REVIEW_PAGE_URI;
518 : }
519 :
520 1 : return NOTATION_PAGE_URI;
521 15 : }
522 :
523 12 : Promise<Ret> OpenProjectScenario::downloadAndOpenCloudProject(int scoreId, const QString& hash, const QString& secret, bool isOwner)
524 : {
525 24 : return runIfNotBusy(BusyStatus::Downloading, [this, scoreId, hash, secret, isOwner]() -> Promise<Ret> {
526 12 : if (!scoreId) {
527 : // Might happen when user tries to open score that saved as a cloud score but upload did not fully succeed
528 1 : LOGE() << "invalid cloud score id";
529 :
530 1 : Ret ret = make_ret(Err::InvalidCloudScoreId);
531 1 : showCloudOpenError(ret);
532 1 : return resolvedPromise(ret);
533 1 : }
534 :
535 22 : return ensureAuthorization().then<Ret>(this, [this, scoreId, hash, secret, isOwner](const RetVal<Val>& auth, auto resolve) {
536 11 : if (!auth.ret) {
537 1 : return resolve(auth.ret);
538 : }
539 :
540 10 : doDownloadAndOpenCloudProject(scoreId, hash, secret, isOwner).onResolve(this, [resolve](const Ret& ret) {
541 5 : (void)resolve(ret);
542 : });
543 :
544 10 : return Promise<Ret>::dummy_result();
545 11 : });
546 24 : });
547 : }
548 :
549 10 : Promise<Ret> OpenProjectScenario::doDownloadAndOpenCloudProject(int scoreId, const QString& hash, const QString& secret, bool isOwner)
550 : {
551 10 : muse::io::path_t localPath = configuration()->cloudProjectPath(scoreId);
552 :
553 10 : if (!isOwner) {
554 4 : return downloadCloudProject(scoreId, localPath, hash, secret, CloudProjectInfo(), isOwner);
555 : }
556 :
557 16 : return museScoreComService()->downloadScoreInfo(scoreId)
558 16 : .then<Ret>(this, [this, scoreId, localPath, hash, secret, isOwner](const RetVal<muse::cloud::ScoreInfo>& scoreInfo,
559 : auto resolve) {
560 8 : if (!scoreInfo.ret) {
561 1 : LOGE() << "Error while downloading score info: " << scoreInfo.ret.toString();
562 1 : showCloudOpenError(scoreInfo.ret);
563 1 : return resolve(scoreInfo.ret);
564 : }
565 :
566 7 : CloudProjectInfo info;
567 7 : info.name = scoreInfo.val.title;
568 7 : info.visibility = scoreInfo.val.visibility;
569 7 : info.sourceUrl = scoreInfo.val.url;
570 7 : info.revisionId = scoreInfo.val.revisionId;
571 :
572 7 : RetVal<CloudProjectInfo> localInfo = mscMetaReader()->readCloudProjectInfo(localPath);
573 :
574 7 : if (localInfo.ret) {
575 2 : if (localInfo.val.revisionId == scoreInfo.val.revisionId) {
576 1 : doOpenCloudProject(localPath, info, isOwner).onResolve(this, [resolve](const Ret& ret) {
577 1 : (void)resolve(ret);
578 : });
579 :
580 1 : return Promise<Ret>::dummy_result();
581 : }
582 : } else {
583 5 : LOGE() << localInfo.ret;
584 : }
585 :
586 6 : downloadCloudProject(scoreId, localPath, hash, secret, info, isOwner).onResolve(this, [resolve](const Ret& ret) {
587 2 : (void)resolve(ret);
588 : });
589 :
590 6 : return Promise<Ret>::dummy_result();
591 15 : });
592 10 : }
593 :
594 8 : Promise<Ret> OpenProjectScenario::downloadCloudProject(int scoreId, const muse::io::path_t& localPath, const QString& hash,
595 : const QString& secret, const CloudProjectInfo& info, bool isOwner)
596 : {
597 16 : return async::make_promise<Ret>([this, scoreId, localPath, hash, secret, info, isOwner](auto resolve) {
598 : // TODO(cloud): conflict checking (don't recklessly overwrite the existing file)
599 8 : auto projectData = std::make_shared<QFile>(localPath.toQString());
600 8 : if (!projectData->open(QIODevice::WriteOnly)) {
601 0 : Ret ret = make_ret(Err::FileOpenError);
602 0 : showCloudOpenError(ret);
603 0 : return resolve(ret);
604 0 : }
605 :
606 8 : m_projectBeingDownloaded.scoreId = scoreId;
607 8 : m_projectBeingDownloaded.progress = museScoreComService()->downloadScore(scoreId, projectData, hash, secret);
608 :
609 8 : m_projectBeingDownloaded.progress->finished().onReceive(this, [this, localPath, info, isOwner, resolve](const ProgressResult& res) {
610 3 : m_projectBeingDownloaded = {};
611 3 : m_projectBeingDownloadedChanged.notify();
612 :
613 3 : if (!res.ret) {
614 1 : LOGE() << res.ret.toString();
615 1 : showCloudOpenError(res.ret);
616 1 : (void)resolve(res.ret);
617 1 : return;
618 : }
619 :
620 2 : doOpenCloudProject(localPath, info, isOwner).onResolve(this, [resolve](const Ret& ret) {
621 2 : (void)resolve(ret);
622 : });
623 : });
624 :
625 8 : m_projectBeingDownloadedChanged.notify();
626 :
627 8 : return Promise<Ret>::dummy_result();
628 24 : });
629 : }
630 :
631 5 : Promise<Ret> OpenProjectScenario::openMuseScoreUrl(const QUrl& url)
632 : {
633 5 : if (url.host() == OPEN_SCORE_URL_HOSTNAME) {
634 4 : return openScoreFromMuseScoreCom(url);
635 : }
636 :
637 1 : return resolvedPromise(make_ret(Err::UnsupportedUrl));
638 : }
639 :
640 4 : Promise<Ret> OpenProjectScenario::openScoreFromMuseScoreCom(const QUrl& url)
641 : {
642 4 : if (isBusy(BusyStatus::Downloading)) {
643 : // TODO: instead of ignoring the open request, queue it?
644 0 : return resolvedPromise(make_ret(Ret::Code::Busy));
645 : }
646 :
647 8 : return runIfNotBusy(BusyStatus::Opening, [this, url]() -> Promise<Ret> {
648 : // Retrieve score id from URL
649 4 : bool ok = false;
650 4 : int scoreId = url.fileName().toInt(&ok);
651 4 : if (!ok || scoreId <= 0) {
652 1 : return resolvedPromise(make_ret(Err::MalformedOpenScoreUrl));
653 : }
654 :
655 : // Ensure logged in
656 6 : return ensureAuthorization().then<Ret>(this, [this, url, scoreId](const RetVal<Val>& auth, auto resolve) {
657 3 : if (!auth.ret) {
658 0 : return resolve(auth.ret);
659 : }
660 :
661 : // Check if user is owner
662 3 : museScoreComService()->downloadScoreInfo(scoreId)
663 3 : .onResolve(this, [this, url, scoreId, resolve](const RetVal<muse::cloud::ScoreInfo>& scoreInfo) {
664 3 : if (!scoreInfo.ret) {
665 0 : LOGE() << "Error while downloading score info: " << scoreInfo.ret.toString();
666 0 : showCloudOpenError(scoreInfo.ret);
667 :
668 0 : (void)resolve(scoreInfo.ret);
669 0 : return;
670 : }
671 :
672 3 : openScoreFromMuseScoreCom(url, scoreId, scoreInfo.val).onResolve(this, [resolve](const Ret& ret) {
673 2 : (void)resolve(ret);
674 : });
675 : });
676 :
677 3 : return Promise<Ret>::dummy_result();
678 3 : });
679 4 : });
680 : }
681 :
682 3 : Promise<Ret> OpenProjectScenario::openScoreFromMuseScoreCom(const QUrl& url, int scoreId, const muse::cloud::ScoreInfo& scoreInfo)
683 : {
684 6 : return async::make_promise<Ret>([this, url, scoreId, scoreInfo](auto resolve) {
685 3 : bool isOwner = QString::number(scoreInfo.owner.id) == museScoreComService()->authorization()->accountInfo().id;
686 :
687 : // If yes, score will be opened as regular cloud score; check if not yet opened
688 3 : if (isOwner) {
689 1 : muse::io::path_t projectPath = configuration()->cloudProjectPath(scoreId);
690 :
691 : // either in this instance
692 1 : if (isProjectOpened(projectPath)) {
693 0 : return resolve(finishOpening());
694 : }
695 :
696 : // or in another one
697 1 : if (multiwindowsProvider()->isProjectAlreadyOpened(projectPath)) {
698 1 : multiwindowsProvider()->activateWindowWithProject(projectPath);
699 1 : return resolve(muse::make_ok());
700 : }
701 1 : }
702 :
703 : // Check if this instance already has an open project
704 2 : if (projectSession()->project()) {
705 1 : QStringList args;
706 1 : args << url.toString();
707 :
708 1 : if (!scoreInfo.title.isEmpty()) {
709 1 : args << "--score-display-name-override" << scoreInfo.title;
710 : }
711 :
712 1 : multiwindowsProvider()->openNewWindow(args);
713 1 : return resolve(muse::make_ok());
714 1 : }
715 :
716 1 : QUrlQuery query(url);
717 1 : QString hash = query.queryItemValue("h");
718 1 : QString secret = query.queryItemValue("secret");
719 :
720 1 : downloadAndOpenCloudProject(scoreId, hash, secret, isOwner).onResolve(this, [resolve](const Ret& ret) {
721 0 : (void)resolve(ret);
722 : });
723 :
724 1 : return Promise<Ret>::dummy_result();
725 7 : });
726 : }
727 :
728 0 : const ProjectBeingDownloaded& OpenProjectScenario::projectBeingDownloaded() const
729 : {
730 0 : return m_projectBeingDownloaded;
731 : }
732 :
733 0 : muse::async::Notification OpenProjectScenario::projectBeingDownloadedChanged() const
734 : {
735 0 : return m_projectBeingDownloadedChanged;
736 : }
737 :
738 11 : Ret OpenProjectScenario::openPageIfNeed(Uri pageUri)
739 : {
740 11 : if (!interactive()->isOpened(pageUri).val) {
741 0 : interactive()->open(pageUri);
742 : }
743 11 : return make_ret(Ret::Code::Ok);
744 : }
745 :
746 23 : bool OpenProjectScenario::isProjectOpened(const muse::io::path_t& scorePath) const
747 : {
748 23 : auto project = projectSession()->project();
749 23 : if (!project) {
750 19 : return false;
751 : }
752 :
753 4 : LOGD() << "project->path: " << project->path() << ", check path: " << scorePath;
754 4 : if (project->path() == scorePath) {
755 1 : return true;
756 : }
757 :
758 3 : return false;
759 23 : }
760 :
761 8 : RecentFile OpenProjectScenario::makeRecentFile(INotationProjectPtr project)
762 : {
763 8 : RecentFile file;
764 8 : file.path = project->path();
765 :
766 8 : if (project->isCloudProject()) {
767 0 : file.displayNameOverride = project->cloudInfo().name;
768 : }
769 :
770 8 : return file;
771 0 : }
772 :
773 0 : Promise<Ret> OpenProjectScenario::revertToLastSaved()
774 : {
775 0 : TRACEFUNC;
776 :
777 0 : auto currentProject = projectSession()->project();
778 0 : if (!currentProject) {
779 0 : return resolvedPromise(make_ret(Err::NoProjectError));
780 : }
781 :
782 0 : muse::io::path_t filePath = currentProject->path();
783 :
784 0 : bool hasUnsavedChanges = projectAutoSaver()->projectHasUnsavedChanges(filePath);
785 0 : if (hasUnsavedChanges) {
786 0 : muse::io::path_t autoSavePath = projectAutoSaver()->projectAutoSavePath(filePath);
787 0 : fileSystem()->remove(autoSavePath);
788 0 : }
789 :
790 0 : return runIfNotBusy(BusyStatus::Opening, [this, filePath]() {
791 0 : return doOpenProject(filePath);
792 0 : });
793 0 : }
794 :
795 7 : Promise<bool> OpenProjectScenario::shouldRetryLoadAfterError(const Ret& ret, const muse::io::path_t& filepath)
796 : {
797 7 : if (ret) {
798 0 : return resolvedPromise(true);
799 : }
800 :
801 7 : switch (static_cast<engraving::Err>(ret.code())) {
802 2 : case engraving::Err::FileTooOld:
803 : case engraving::Err::FileOld300Format:
804 2 : return askIfUserAgreesToOpenProjectWithIncompatibleVersion(ret.text());
805 2 : case engraving::Err::FileTooNew:
806 2 : warnFileTooNew(filepath);
807 2 : return resolvedPromise(configuration()->disableVersionChecking());
808 1 : case engraving::Err::FileCorrupted:
809 2 : return askIfUserAgreesToOpenCorruptedProject(io::filename(filepath).toString(), ret.text());
810 1 : case engraving::Err::FileCriticallyCorrupted:
811 1 : warnProjectCriticallyCorrupted(io::filename(filepath).toString(), ret.text());
812 2 : return resolvedPromise(false);
813 1 : default:
814 1 : warnProjectCannotBeOpened(ret, filepath);
815 1 : break;
816 : }
817 :
818 2 : return resolvedPromise(false);
819 : }
820 :
821 2 : Promise<bool> OpenProjectScenario::askIfUserAgreesToOpenProjectWithIncompatibleVersion(const std::string& errorText)
822 : {
823 2 : IInteractive::ButtonData openAnywayBtn(IInteractive::Button::CustomButton, muse::trc("project", "Open anyway"), true /*accent*/);
824 :
825 12 : return interactive()->warning(errorText, IInteractive::Text(), {
826 2 : interactive()->buttonData(IInteractive::Button::Cancel),
827 : openAnywayBtn
828 : }, openAnywayBtn.btn)
829 4 : .then<bool>(this, [openAnywayBtn](const IInteractive::Result& res, auto resolve) {
830 2 : return resolve(res.isButton(openAnywayBtn.btn));
831 4 : });
832 6 : }
833 :
834 2 : void OpenProjectScenario::warnFileTooNew(const muse::io::path_t& filepath)
835 : {
836 8 : interactive()->error(muse::qtrc("project", "Cannot read file %1").arg(io::toNativeSeparators(filepath).toQString()).toStdString(),
837 2 : muse::mtrc("project", "This file was saved using a newer version of MuseScore Studio. "
838 : "Please visit <a href=\"%1\">MuseScore.org</a> to obtain the latest version.")
839 4 : .arg(u"https://musescore.org").toStdString());
840 2 : }
841 :
842 1 : Promise<bool> OpenProjectScenario::askIfUserAgreesToOpenCorruptedProject(const String& projectName, const std::string& errorText)
843 : {
844 1 : std::string title = muse::mtrc("project", "File “%1” is corrupted").arg(projectName).toStdString();
845 1 : IInteractive::Text text;
846 1 : text.text = muse::trc("project", "This file contains errors that could cause MuseScore Studio to malfunction.");
847 1 : text.detailedText = errorText;
848 :
849 1 : IInteractive::ButtonData openAnywayBtn(IInteractive::Button::CustomButton, muse::trc("project", "Open anyway"), true /*accent*/);
850 :
851 6 : return interactive()->warning(title, text, {
852 1 : interactive()->buttonData(IInteractive::Button::Cancel),
853 : openAnywayBtn
854 : }, openAnywayBtn.btn)
855 2 : .then<bool>(this, [openAnywayBtn](const IInteractive::Result& res, auto resolve) {
856 1 : return resolve(res.isButton(openAnywayBtn.btn));
857 2 : });
858 3 : }
859 :
860 1 : void OpenProjectScenario::warnProjectCriticallyCorrupted(const String& projectName, const std::string& errorText)
861 : {
862 1 : std::string title = muse::mtrc("project", "File “%1” is corrupted and cannot be opened").arg(projectName).toStdString();
863 1 : IInteractive::Text text;
864 1 : text.text = muse::trc("project", "Get help for this issue on MuseScore.org.");
865 1 : text.detailedText = errorText;
866 :
867 1 : IInteractive::ButtonData getHelpBtn(IInteractive::Button::CustomButton, muse::trc("project", "Get help"), true /*accent*/);
868 :
869 6 : interactive()->error(title, text, {
870 1 : interactive()->buttonData(IInteractive::Button::Cancel),
871 : getHelpBtn
872 1 : }, getHelpBtn.btn).onResolve(this, [this, getHelpBtn](const IInteractive::Result& res) {
873 1 : if (res.isButton(getHelpBtn.btn)) {
874 0 : platformInteractive()->openUrl(configuration()->supportForumUrl());
875 : }
876 1 : });
877 3 : }
878 :
879 1 : void OpenProjectScenario::warnProjectCannotBeOpened(const Ret& ret, const muse::io::path_t& filepath)
880 : {
881 1 : std::string title = muse::mtrc("project", "Cannot read file %1").arg(io::toNativeSeparators(filepath).toString()).toStdString();
882 1 : std::string body;
883 :
884 1 : switch (ret.code()) {
885 1 : case int(engraving::Err::FileNotFound):
886 1 : body = muse::trc("project", "This file does not exist or cannot be accessed at the moment.");
887 1 : break;
888 0 : case int(engraving::Err::FileOpenError):
889 0 : body = muse::trc("project",
890 0 : "This file could not be opened. Please make sure that MuseScore Studio has permission to read this file.");
891 0 : break;
892 0 : default:
893 0 : if (!ret.text().empty()) {
894 0 : body = ret.text();
895 : } else {
896 0 : body = muse::trc("project", "An error occurred while reading this file.");
897 : }
898 : }
899 :
900 1 : interactive()->error(title, body);
901 1 : }
902 :
903 2 : async::Promise<io::path_t> OpenProjectScenario::selectScoreOpeningFile() const
904 : {
905 : std::string allExt = "*.mscz *.mxl *.musicxml *.xml *.mid *.midi *.kar *.md *.mgu *.sgu *.cap *.capx "
906 2 : "*.ove *.scw *.bmw *.bww *.gtp *.gp3 *.gp4 *.gp5 *.gpx *.gp *.ptb *.mei *.mnx *.json *.tef *.mscx *.mscs *.mscz~";
907 :
908 4 : std::vector<std::string> filter { muse::trc("project", "All supported files") + " (" + allExt + ")",
909 2 : muse::trc("project", "MuseScore files") + " (*.mscz)",
910 2 : muse::trc("project", "MusicXML files") + " (*.mxl *.musicxml *.xml)",
911 2 : muse::trc("project", "MIDI files") + " (*.mid *.midi *.kar)",
912 2 : muse::trc("project", "MNX files [experimental]") + " (*.mnx *.json)",
913 2 : muse::trc("project", "MuseData files") + " (*.md)",
914 2 : muse::trc("project", "Capella files") + " (*.cap *.capx)",
915 2 : muse::trc("project", "BB files [experimental]") + " (*.mgu *.sgu)",
916 2 : muse::trc("project", "Overture / Score Writer files [experimental]") + " (*.ove *.scw)",
917 2 : muse::trc("project", "Bagpipe Music Writer files [experimental]") + " (*.bmw *.bww)",
918 2 : muse::trc("project", "Guitar Pro files") + " (*.gtp *.gp3 *.gp4 *.gp5 *.gpx *.gp)",
919 2 : muse::trc("project", "Power Tab Editor files [experimental]") + " (*.ptb)",
920 2 : muse::trc("project", "MEI files") + " (*.mei)",
921 2 : muse::trc("project", "TablEdit files [experimental]") + " (*.tef)",
922 2 : muse::trc("project", "Uncompressed MuseScore folders [experimental]") + " (*.mscx)",
923 2 : muse::trc("project", "MuseScore developer files") + " (*.mscs)",
924 38 : muse::trc("project", "MuseScore backup files") + " (*.mscz~)" };
925 :
926 2 : muse::io::path_t defaultDir = configuration()->lastOpenedProjectsPath();
927 :
928 2 : if (defaultDir.empty()) {
929 2 : defaultDir = configuration()->userProjectsPath();
930 : }
931 :
932 2 : if (defaultDir.empty()) {
933 2 : defaultDir = configuration()->defaultUserProjectsPath();
934 : }
935 :
936 4 : return interactive()->selectOpeningFile(muse::trc("project", "Open"), defaultDir, filter);
937 6 : }
938 :
939 14 : Promise<RetVal<Val> > OpenProjectScenario::ensureAuthorization() const
940 : {
941 14 : bool userAuthorized = museScoreComService()->authorization()->userAuthorized().val;
942 :
943 14 : if (userAuthorized) {
944 12 : return resolvedPromise(RetVal<Val>::make_ok(Val()));
945 : }
946 :
947 2 : const std::string dialogText = muse::trc("project/save", "Log in or create a free account on MuseScore.com to open this score.");
948 :
949 2 : UriQuery query("muse://cloud/requireauthorization");
950 6 : query.addParam("text", Val(dialogText));
951 6 : query.addParam("cloudCode", Val(muse::cloud::MUSESCORE_COM_CLOUD_CODE));
952 6 : query.addParam("publishingScore", Val(false));
953 2 : return openDialog(query);
954 2 : }
|