• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ossia / score / 31975605394

16 Aug 2026 10:10PM UTC coverage: 20.35%. First build
31975605394

Pull #2211

github

web-flow
Merge 1defa584c into bb5e533af
Pull Request #2211: project: collect, relink, trim and archive a project's files

1182 of 1996 new or added lines in 43 files covered. (59.22%)

43464 of 213584 relevant lines covered (20.35%)

5552.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

42.05
/src/plugins/score-lib-process/Process/ProjectFilesApplicationPlugin.cpp
1
#include <Process/MediaTrimDialog.hpp>
2
#include <Process/MissingFilesDialog.hpp>
3
#include <Process/ProjectArchive.hpp>
4
#include <Process/UnusedFilesDialog.hpp>
5
#include <Process/ProjectConsolidation.hpp>
6
#include <Process/ProjectConsolidationDialog.hpp>
7
#include <Process/ProjectFilesApplicationPlugin.hpp>
8

9
#include <score/actions/ActionManager.hpp>
10
#include <score/actions/MenuManager.hpp>
11
#include <score/tools/Zip.hpp>
12
#include <score/widgets/HelpInteraction.hpp>
13

14
#include <core/document/Document.hpp>
15
#include <core/presenter/DocumentManager.hpp>
16

17
#include <QAction>
18
#include <QCoreApplication>
19
#include <QFileDialog>
20
#include <QFileInfo>
21
#include <QLocale>
22
#include <QMainWindow>
23
#include <QMenu>
24
#include <QMessageBox>
25
#include <QProgressDialog>
26
#include <QTimer>
27

28
SCORE_DECLARE_ACTION(
425✔
29
    ConsolidateProject, "&Consolidate project...", Common, QKeySequence::UnknownKey)
30
SCORE_DECLARE_ACTION(
425✔
31
    LocateMissingFiles, "&Locate missing files...", Common, QKeySequence::UnknownKey)
32
SCORE_DECLARE_ACTION(
425✔
33
    TrimProjectMedia, "&Shorten media files to what is played...", Common,
34
    QKeySequence::UnknownKey)
35
SCORE_DECLARE_ACTION(
425✔
36
    RemoveUnusedFiles, "&Remove unused files...", Common, QKeySequence::UnknownKey)
37
SCORE_DECLARE_ACTION(
425✔
38
    ArchiveProject, "&Archive project...", Common, QKeySequence::UnknownKey)
39

40
namespace Process
41
{
42

43
ProjectFilesApplicationPlugin::ProjectFilesApplicationPlugin(
182✔
44
    const score::GUIApplicationContext& ctx)
45
    : score::GUIApplicationPlugin{ctx}
182✔
46
{
182✔
NEW
47
}
×
48

49
ProjectFilesApplicationPlugin::~ProjectFilesApplicationPlugin() = default;
362✔
50

51
score::Document*
NEW
52
ProjectFilesApplicationPlugin::documentWithFolder(const QString& what)
×
53
{
NEW
54
  auto doc = context.docManager.currentDocument();
×
NEW
55
  if(!doc)
×
NEW
56
    return nullptr;
×
57

58
  // Every path this writes is relative to the document's own folder, so there
59
  // has to be one. This is the "save your set into its own project folder
60
  // first" rule every DAW ends up teaching its users; ask for it up front
61
  // rather than silently working in the wrong place.
NEW
62
  if(doc->metadata().projectFolder().isEmpty())
×
63
  {
NEW
64
    const auto res = QMessageBox::question(
×
NEW
65
        context.mainWindow, QObject::tr("Save the document first"),
×
NEW
66
        QObject::tr("%1 works next to the document, so the document has to be "
×
67
                    "saved somewhere first.\n\nSave it now?")
NEW
68
            .arg(what),
×
NEW
69
        QMessageBox::Save | QMessageBox::Cancel);
×
NEW
70
    if(res != QMessageBox::Save)
×
NEW
71
      return nullptr;
×
NEW
72
    if(!context.docManager.saveDocumentAs(*doc))
×
NEW
73
      return nullptr;
×
NEW
74
    if(doc->metadata().projectFolder().isEmpty())
×
NEW
75
      return nullptr;
×
NEW
76
  }
×
77

NEW
78
  return doc;
×
NEW
79
}
×
80

NEW
81
void ProjectFilesApplicationPlugin::consolidate()
×
82
{
NEW
83
  auto doc = documentWithFolder(QObject::tr("Consolidating"));
×
NEW
84
  if(!doc)
×
NEW
85
    return;
×
86

NEW
87
  ProjectConsolidationDialog dialog{doc->context(), context.mainWindow};
×
NEW
88
  if(dialog.exec() != QDialog::Accepted)
×
NEW
89
    return;
×
90

91
  // Save right away: the copies on disk and the rewritten references only
92
  // make sense together, and a document closed without saving would leave
93
  // the collected files orphaned.
NEW
94
  context.docManager.saveDocument(*doc);
×
NEW
95
}
×
96

NEW
97
void ProjectFilesApplicationPlugin::locateMissingFiles()
×
98
{
NEW
99
  auto doc = context.docManager.currentDocument();
×
NEW
100
  if(!doc)
×
NEW
101
    return;
×
102

NEW
103
  auto* dialog = new MissingFilesDialog{*doc, context.mainWindow};
×
NEW
104
  dialog->show();
×
NEW
105
}
×
106

NEW
107
void ProjectFilesApplicationPlugin::trimMedia()
×
108
{
NEW
109
  auto doc = documentWithFolder(QObject::tr("Trimming"));
×
NEW
110
  if(!doc)
×
NEW
111
    return;
×
112

NEW
113
  MediaTrimDialog dialog{doc->context(), context.mainWindow};
×
NEW
114
  if(dialog.exec() != QDialog::Accepted)
×
NEW
115
    return;
×
116

NEW
117
  context.docManager.saveDocument(*doc);
×
NEW
118
}
×
119

NEW
120
void ProjectFilesApplicationPlugin::removeUnused()
×
121
{
NEW
122
  auto doc = documentWithFolder(QObject::tr("Cleaning up"));
×
NEW
123
  if(!doc)
×
NEW
124
    return;
×
125

NEW
126
  UnusedFilesDialog dialog{doc->context(), context.mainWindow};
×
NEW
127
  dialog.exec();
×
NEW
128
}
×
129

NEW
130
void ProjectFilesApplicationPlugin::archive()
×
131
{
NEW
132
  auto doc = documentWithFolder(QObject::tr("Archiving"));
×
NEW
133
  if(!doc)
×
NEW
134
    return;
×
135

136
  const QString suggested
NEW
137
      = doc->metadata().projectFolder() + '/' + doc->metadata().documentName()
×
NEW
138
        + QStringLiteral(".zip");
×
NEW
139
  const QString destination = QFileDialog::getSaveFileName(
×
NEW
140
      context.mainWindow, QObject::tr("Archive project as"), suggested,
×
NEW
141
      QObject::tr("Archives (*.zip)"));
×
NEW
142
  if(destination.isEmpty())
×
NEW
143
    return;
×
144

145
  // Collect first: an archive of a project whose media is scattered over the
146
  // machine would be an archive of nothing.
NEW
147
  const auto report = consolidateProjectFiles(doc->context(), {});
×
148
  context.docManager.saveDocument(*doc);
149

150
  const auto contents = projectArchiveContents(doc->context(), report);
NEW
151
  if(contents.empty())
×
152
  {
NEW
153
    QMessageBox::warning(
×
NEW
154
        context.mainWindow, QObject::tr("Nothing to archive"),
×
NEW
155
        QObject::tr("No file of this project could be found on disk."));
×
NEW
156
    return;
×
157
  }
158

159
  QProgressDialog progress{
160
      QObject::tr("Archiving %1 file(s), %2...")
161
          .arg(contents.size())
162
          .arg(QLocale{}.formattedDataSize(archiveContentsSize(contents))),
163
      QObject::tr("Cancel"), 0, int(contents.size()), context.mainWindow};
164
  progress.setWindowModality(Qt::WindowModal);
165

166
  QString error;
167
  const bool ok = score::writeZipArchive(
NEW
168
      destination, contents, /*level=*/1, error, [&](int done, int total) {
×
NEW
169
    progress.setValue(done);
×
NEW
170
    QCoreApplication::processEvents();
×
NEW
171
    return !progress.wasCanceled();
×
172
      });
173

174
  progress.close();
175

NEW
176
  if(!ok)
×
177
  {
NEW
178
    QMessageBox::warning(context.mainWindow, QObject::tr("Archiving failed"), error);
×
NEW
179
    return;
×
180
  }
181

182
  const int missing = report.count(FileAction::Missing);
183
  const int external = report.count(FileAction::Unsupported);
184
  QString note = QObject::tr("%1 holds %2 file(s).")
185
                     .arg(QFileInfo{destination}.fileName())
186
                     .arg(contents.size());
NEW
187
  if(missing > 0)
×
NEW
188
    note += QObject::tr("\n\n%1 file(s) could not be found and are NOT in the "
×
189
                        "archive.")
NEW
190
                .arg(missing);
×
NEW
191
  if(external > 0)
×
NEW
192
    note += QObject::tr("\n\n%1 external dependency/ies (plug-ins, folders) must "
×
193
                        "be installed on the other machine.")
NEW
194
                .arg(external);
×
195

196
  QMessageBox::information(context.mainWindow, QObject::tr("Project archived"), note);
NEW
197
}
×
198

199
void ProjectFilesApplicationPlugin::on_loadedDocument(score::Document& doc)
5✔
200
{
201
  if(!context.mainWindow)
5✔
202
    return;
3✔
203

204
  // The document is still being built: look at it once the load has settled,
205
  // and never block it.
206
  QPointer<score::Document> guard{&doc};
2✔
207
  QTimer::singleShot(0, this, [this, guard] {
4✔
208
    if(!guard)
2✔
NEW
209
      return;
×
210
    if(MissingFilesDialog::nothingMissing(guard->context()))
2✔
211
      return;
1✔
212

213
    auto* dialog = new MissingFilesDialog{*guard, context.mainWindow};
1✔
214
    dialog->show();
1✔
215
  });
2✔
216
}
5✔
217

218
void ProjectFilesApplicationPlugin::on_documentSaveAs(
30✔
219
    score::Document& doc, const QString& newFileName)
220
{
221
  const QString before = doc.metadata().projectFolder();
30✔
222
  if(before.isEmpty())
30✔
223
    return; // never saved: nothing was relative to anything yet
30✔
224

NEW
225
  const QString after = QFileInfo{newFileName}.absolutePath();
×
NEW
226
  if(before == after || QFileInfo{after}.canonicalFilePath() == before)
×
NEW
227
    return; // same folder: the relative references still mean what they meant
×
228

NEW
229
  const auto& ctx = doc.context();
×
NEW
230
  const int relative = countProjectRelativeFiles(ctx);
×
NEW
231
  if(relative == 0)
×
NEW
232
    return;
×
233

234
  // Saving elsewhere silently turns every "<PROJECT>:..." into a file that is
235
  // not there. Neither outcome is obviously right, so ask; but never leave the
236
  // document in the broken third state.
237
  // Without a window to ask in (scripted saves), re-anchor: copying gigabytes
238
  // of media is not something to do behind a script's back, and the document
239
  // still works afterwards.
NEW
240
  auto choice = QMessageBox::No;
×
NEW
241
  if(context.mainWindow)
×
242
  {
NEW
243
    choice = QMessageBox::StandardButton(QMessageBox::question(
×
NEW
244
        context.mainWindow, QObject::tr("Media of this project"),
×
NEW
245
        QObject::tr("This project is being saved to a different folder, and %1 of "
×
246
                    "its file(s) live next to the previous one.\n\n"
247
                    "Copy them into the new folder?\n\n"
248
                    "Choosing No keeps them where they are and points the project "
249
                    "at them with absolute paths.")
NEW
250
            .arg(relative),
×
NEW
251
        QMessageBox::Yes | QMessageBox::No));
×
NEW
252
  }
×
253

NEW
254
  if(choice == QMessageBox::Yes)
×
NEW
255
    consolidateProjectFiles(ctx, {}, after);
×
256
  else
257
    reanchorProjectFiles(ctx);
258
}
30✔
259

260
score::GUIElements ProjectFilesApplicationPlugin::makeGUIElements()
182✔
261
{
262
  score::GUIElements e;
182✔
263
  if(!context.mainWindow)
182✔
264
    return e;
97✔
265

266
  // Five entries is too many to leave loose in the File menu, and two of them
267
  // -- shortening files and removing files -- read as near-synonyms until they
268
  // are seen side by side. Their own submenu does both jobs.
269
  auto* project = new QMenu{QObject::tr("Project &files")};
85✔
270
  e.menus.emplace_back(project, score::Menus::ProjectFiles());
85✔
271

272
  auto& file = context.menus.get().at(score::Menus::File());
85✔
273
  auto& cond = context.actions.condition<score::EnableActionIfDocument>();
85✔
274

275
  {
276
    // A plug-in's additions land at the end of the menu, which here is after
277
    // Quit. Go in above the last separator instead, beside Save As.
278
    QAction* before = nullptr;
85✔
279
    const auto existing = file.menu()->actions();
85✔
280
    for(int i = existing.size() - 1; i >= 0; --i)
255✔
281
    {
282
      if(existing[i]->isSeparator())
255✔
283
      {
284
        before = existing[i];
85✔
285
        break;
85✔
286
      }
287
    }
170✔
288

289
    if(before)
85✔
290
      file.menu()->insertMenu(before, project);
85✔
291
    else
NEW
292
      file.menu()->addMenu(project);
×
293
  }
85✔
294

295
  const auto add = [&](QAction*& action, auto&& slot, const QString& help) {
510✔
296
    action = new QAction{context.mainWindow};
425✔
297
    score::setHelp(action, help);
425✔
298
    connect(action, &QAction::triggered, this, slot);
425✔
299
    project->addAction(action);
425✔
300
  };
425✔
301

302
  add(m_consolidate, &ProjectFilesApplicationPlugin::consolidate,
85✔
303
      QObject::tr("Copy every file the document uses next to it, and make the "
85✔
304
                  "references relative to the project."));
305
  e.actions.add<Actions::ConsolidateProject>(m_consolidate);
85✔
306
  cond.add<Actions::ConsolidateProject>();
85✔
307

308
  add(m_locate, &ProjectFilesApplicationPlugin::locateMissingFiles,
85✔
309
      QObject::tr("List the files this document cannot find and help point it at "
85✔
310
                  "them."));
311
  e.actions.add<Actions::LocateMissingFiles>(m_locate);
85✔
312
  cond.add<Actions::LocateMissingFiles>();
85✔
313

314
  project->addSeparator();
85✔
315

316
  add(m_unused, &ProjectFilesApplicationPlugin::removeUnused,
85✔
317
      QObject::tr("List the files sitting in the project folder that nothing in "
85✔
318
                  "the document uses any more, and get rid of them."));
319
  e.actions.add<Actions::RemoveUnusedFiles>(m_unused);
85✔
320
  cond.add<Actions::RemoveUnusedFiles>();
85✔
321

322
  add(m_trim, &ProjectFilesApplicationPlugin::trimMedia,
85✔
323
      QObject::tr("Shorten media files that are used down to the parts that are "
85✔
324
                  "played. Whole files nothing uses are removed by the entry "
325
                  "above instead."));
326
  e.actions.add<Actions::TrimProjectMedia>(m_trim);
85✔
327
  cond.add<Actions::TrimProjectMedia>();
85✔
328

329
  project->addSeparator();
85✔
330

331
  add(m_archive, &ProjectFilesApplicationPlugin::archive,
85✔
332
      QObject::tr("Collect everything and write the whole project into a single "
85✔
333
                  "zip file."));
334
  e.actions.add<Actions::ArchiveProject>(m_archive);
85✔
335
  cond.add<Actions::ArchiveProject>();
85✔
336

337
  return e;
85✔
338
}
182✔
339
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc