source: appstream-generator/src/asgen/engine.d @ 4841

Last change on this file since 4841 was 4841, checked in by Juanma, 2 years ago

Initial release

File size: 25.7 KB
Line 
1/*
2 * Copyright (C) 2016 Matthias Klumpp <matthias@tenstral.net>
3 *
4 * Licensed under the GNU Lesser General Public License Version 3
5 *
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Lesser General Public License as published by
8 * the Free Software Foundation, either version 3 of the license, or
9 * (at your option) any later version.
10 *
11 * This software is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 * GNU Lesser General Public License for more details.
15 *
16 * You should have received a copy of the GNU Lesser General Public License
17 * along with this software.  If not, see <http://www.gnu.org/licenses/>.
18 */
19
20module asgen.engine;
21
22import std.stdio;
23import std.parallelism;
24import std.string : format, count, toLower, startsWith;
25import std.array : Appender, appender, empty;
26import std.path : buildPath, buildNormalizedPath;
27import std.file : mkdirRecurse, rmdirRecurse;
28import std.algorithm : canFind, sort, SwapStrategy;
29import std.typecons : scoped;
30static import std.file;
31import appstream.Component;
32
33import asgen.config;
34import asgen.logging;
35import asgen.extractor;
36import asgen.datastore;
37import asgen.contentsstore;
38import asgen.result;
39import asgen.hint;
40import asgen.reportgenerator;
41import asgen.utils : copyDir, stringArrayToByteArray;
42
43import asgen.backends.interfaces;
44import asgen.backends.dummy;
45import asgen.backends.debian;
46import asgen.backends.ubuntu;
47import asgen.backends.archlinux;
48import asgen.backends.rpmmd;
49
50import asgen.handlers.iconhandler;
51
52
53final class Engine
54{
55
56private:
57    Config conf;
58    PackageIndex pkgIndex;
59
60    DataStore dstore;
61    ContentsStore cstore;
62
63    bool m_forced;
64
65public:
66
67    this ()
68    {
69        this.conf = Config.get ();
70
71        switch (conf.backend) {
72            case Backend.Dummy:
73                pkgIndex = new DummyPackageIndex (conf.archiveRoot);
74                break;
75            case Backend.Debian:
76                pkgIndex = new DebianPackageIndex (conf.archiveRoot);
77                break;
78            case Backend.Ubuntu:
79                pkgIndex = new UbuntuPackageIndex (conf.archiveRoot);
80                break;
81            case Backend.Archlinux:
82                pkgIndex = new ArchPackageIndex (conf.archiveRoot);
83                break;
84            case Backend.RpmMd:
85                pkgIndex = new RPMPackageIndex (conf.archiveRoot);
86                break;
87            default:
88                throw new Exception ("No backend specified, can not continue!");
89        }
90
91        // create cache in cache directory on workspace
92        dstore = new DataStore ();
93        dstore.open (conf);
94
95        // open package contents cache
96        cstore = new ContentsStore ();
97        cstore.open (conf);
98
99        // for Cairo/Fontconfig issues with multithreading
100        import asgen.image : setupFontconfigMutex;
101        if (conf.featureEnabled (GeneratorFeature.PROCESS_FONTS))
102            setupFontconfigMutex ();
103    }
104
105    @property
106    bool forced ()
107    {
108        return m_forced;
109    }
110
111    @property
112    void forced (bool v)
113    {
114        m_forced = v;
115    }
116
117    private void gcCollect ()
118    {
119        static import core.memory;
120        logDebug ("GC collection cycle triggered explicitly.");
121        core.memory.GC.collect ();
122    }
123
124    /**
125     * Extract metadata from a software container (usually a distro package).
126     * The result is automatically stored in the database.
127     */
128    private void processPackages (Package[] pkgs, IconHandler iconh)
129    {
130        auto mde = scoped!DataExtractor (dstore, iconh);
131        foreach (ref pkg; parallel (pkgs)) {
132            immutable pkid = pkg.id;
133/*            if (dstore.packageExists (pkid))
134                continue;*/
135
136            auto res = mde.processPackage (pkg);
137            synchronized (dstore) {
138                // write resulting data into the database
139                dstore.addGeneratorResult (this.conf.metadataType, res);
140
141                logInfo ("Processed %s, components: %s, hints: %s", res.pkid, res.componentsCount (), res.hintsCount ());
142            }
143
144            // we don't need this package anymore
145            pkg.close ();
146        }
147    }
148
149    /**
150     * Populate the contents index with new contents data. While we are at it, we can also mark
151     * some uninteresting packages as to-be-ignored, so we don't waste time on them
152     * during the following metadata extraction.
153     *
154     * Returns: True in case we have new interesting packages, false otherwise.
155     **/
156    private bool seedContentsData (Suite suite, string section, string arch)
157    {
158        bool packageInteresting (const string[] contents)
159        {
160            foreach (ref c; contents) {
161                if (c.startsWith ("/usr/share/applications/"))
162                    return true;
163                if (c.startsWith ("/usr/share/metainfo/"))
164                    return true;
165                if (c.startsWith ("/usr/share/appdata/"))
166                    return true;
167            }
168
169            return false;
170        }
171
172        // check if the index has changed data, skip the update if there's nothing new
173        if ((!pkgIndex.hasChanges (dstore, suite.name, section, arch)) && (!this.forced)) {
174            logDebug ("Skipping contents cache update for %s/%s [%s], index has not changed.", suite.name, section, arch);
175            return false;
176        }
177
178        logInfo ("Scanning new packages for %s/%s [%s]", suite.name, section, arch);
179
180        // get contents information for packages and add them to the database
181        auto interestingFound = false;
182
183        // First get the contents (only) of all packages in the base suite
184        if (!suite.baseSuite.empty) {
185            logInfo ("Scanning new packages for base suite %s/%s [%s]", suite.baseSuite, section, arch);
186            auto baseSuitePkgs = pkgIndex.packagesFor (suite.baseSuite, section, arch);
187            foreach (ref pkg; parallel (baseSuitePkgs, 8)) {
188                immutable pkid = pkg.id;
189
190                if (!cstore.packageExists (pkid)) {
191                    cstore.addContents (pkid, pkg.contents);
192                    logInfo ("Scanned %s for base suite.", pkid);
193                }
194            }
195        }
196
197        // And then scan the suite itself - here packages can be 'interesting'
198        // in that they might end up in the output.
199        auto pkgs = pkgIndex.packagesFor (suite.name, section, arch);
200        foreach (ref pkg; parallel (pkgs, 8)) {
201            immutable pkid = pkg.id;
202
203            string[] contents;
204            if (cstore.packageExists (pkid)) {
205                if (dstore.packageExists (pkid)) {
206                    // TODO: Unfortunately, packages can move between suites without changing their ID.
207                    // This means as soon as we have an interesting package, even if we already processed it,
208                    // we need to regenerate the output metadata.
209                    // For that to happen, we set interestingFound to true here. Later, a more elegent solution
210                    // would be desirable here, ideally one which doesn't force us to track which package is
211                    // in which suite as well.
212                    if (!dstore.isIgnored (pkid))
213                        interestingFound = true;
214                    continue;
215                }
216                // we will complement the main database with ignore data, in case it
217                // went missing.
218                contents = cstore.getContents (pkid);
219            } else {
220                // add contents to the index
221                contents = pkg.contents;
222                cstore.addContents (pkid, contents);
223            }
224
225            // check if we can already mark this package as ignored, and print some log messages
226            if (!packageInteresting (contents)) {
227                dstore.setPackageIgnore (pkid);
228                logInfo ("Scanned %s, no interesting files found.", pkid);
229                // we won't use this anymore
230                pkg.close ();
231            } else {
232                logInfo ("Scanned %s, could be interesting.", pkid);
233                interestingFound = true;
234            }
235        }
236
237        return interestingFound;
238    }
239
240    private string getMetadataHead (Suite suite, string section)
241    {
242        import std.datetime : Clock;
243        version (GNU)
244            import core.time : FracSec;
245        else
246            import core.time : Duration;
247
248        string head;
249        immutable origin = "%s-%s-%s".format (conf.projectName.toLower, suite.name.toLower, section.toLower);
250
251        auto time = Clock.currTime ();
252        version (GNU)
253            time.fracSec = FracSec.zero; // we don't want fractional seconds.
254        else
255            time.fracSecs = Duration.zero; // for newer Phobos
256        immutable timeStr = time.toISOString ();
257
258        string mediaPoolUrl = buildPath (conf.mediaBaseUrl, "pool");
259        if (conf.featureEnabled (GeneratorFeature.IMMUTABLE_SUITES)) {
260            mediaPoolUrl = buildPath (conf.mediaBaseUrl, suite.name);
261        }
262
263        if (conf.metadataType == DataType.XML) {
264            head = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
265            head ~= format ("<components version=\"%s\" origin=\"%s\"", conf.formatVersionStr, origin);
266            if (suite.dataPriority != 0)
267                head ~= format (" priority=\"%s\"", suite.dataPriority);
268            if (!conf.mediaBaseUrl.empty ())
269                head ~= format (" media_baseurl=\"%s\"", mediaPoolUrl);
270            if (conf.featureEnabled (GeneratorFeature.METADATA_TIMESTAMPS))
271                head ~= format (" time=\"%s\"", timeStr);
272            head ~= ">";
273        } else {
274            head = "---\n";
275            head ~= format ("File: DEP-11\n"
276                           "Version: '%s'\n"
277                           "Origin: %s",
278                           conf.formatVersionStr,
279                           origin);
280            if (!conf.mediaBaseUrl.empty ())
281                head ~= format ("\nMediaBaseUrl: %s", mediaPoolUrl);
282            if (suite.dataPriority != 0)
283                head ~= format ("\nPriority: %s", suite.dataPriority);
284            if (conf.featureEnabled (GeneratorFeature.METADATA_TIMESTAMPS))
285                head ~= format ("\nTime: %s", timeStr);
286        }
287
288        return head;
289    }
290
291    /**
292     * Export metadata and issue hints from the database and store them as files.
293     */
294    private void exportData (Suite suite, string section, string arch, Package[] pkgs, bool withIconTar = false)
295    {
296        import asgen.zarchive;
297        auto mdataFile = appender!string;
298        auto hintsFile = appender!string;
299
300        // reserve some space for our data
301        mdataFile.reserve (pkgs.length / 2);
302        hintsFile.reserve (240);
303
304        // prepare hints file
305        hintsFile ~= "[";
306
307        logInfo ("Exporting data for %s (%s/%s)", suite.name, section, arch);
308
309        // add metadata document header
310        mdataFile ~= getMetadataHead (suite, section);
311        mdataFile ~= "\n";
312
313        // prepare destination
314        immutable dataExportDir = buildPath (conf.dataExportDir, suite.name, section);
315        immutable hintsExportDir = buildPath (conf.hintsExportDir, suite.name, section);
316
317        mkdirRecurse (dataExportDir);
318        mkdirRecurse (hintsExportDir);
319
320        // prepare icon-tarball array
321        immutable iconTarSizes = ["64", "128"];
322        Appender!(immutable(string)[])[string] iconTarFiles;
323        if (withIconTar) {
324            foreach (size; iconTarSizes) {
325                iconTarFiles[size] = appender!(immutable(string)[]);
326            }
327        }
328
329        immutable useImmutableSuites = conf.featureEnabled (GeneratorFeature.IMMUTABLE_SUITES);
330        // select the media export target directory
331        string mediaExportDir;
332        if (useImmutableSuites)
333            mediaExportDir = buildNormalizedPath (dstore.mediaExportPoolDir, "..", suite.name);
334        else
335            mediaExportDir = dstore.mediaExportPoolDir;
336
337        // collect metadata, icons and hints for the given packages
338        bool firstHintEntry = true;
339        logDebug ("Building final metadata and hints files.");
340        foreach (ref pkg; parallel (pkgs, 100)) {
341            immutable pkid = pkg.id;
342            auto gcids = dstore.getGCIDsForPackage (pkid);
343            if (gcids !is null) {
344                auto mres = dstore.getMetadataForPackage (conf.metadataType, pkid);
345                if (!mres.empty) {
346                    synchronized (this) {
347                        foreach (ref md; mres)
348                            mdataFile ~= "%s\n".format (md);
349                    }
350                }
351
352                // nothing left to do if we don't need to deal with icon tarballs and
353                // immutable suites.
354                if ((!useImmutableSuites) && (!withIconTar))
355                    continue;
356
357                foreach (ref gcid; gcids) {
358                    // Symlink data from the pool to the suite-specific directories
359                    if (useImmutableSuites) {
360                        immutable gcidMediaPoolPath = buildPath (dstore.mediaExportPoolDir, gcid);
361                        immutable gcidMediaSuitePath = buildPath (mediaExportDir, gcid);
362                        if ((!std.file.exists (gcidMediaSuitePath)) && (std.file.exists (gcidMediaPoolPath)))
363                            copyDir (gcidMediaPoolPath, gcidMediaSuitePath, true);
364                    }
365
366                    // compile list of icon-tarball files
367                    if (withIconTar) {
368                        foreach (ref size; iconTarSizes) {
369                            immutable iconDir = buildPath (mediaExportDir, gcid, "icons", "%sx%s".format (size, size));
370                            if (!std.file.exists (iconDir))
371                                continue;
372                            foreach (ref path; std.file.dirEntries (iconDir, std.file.SpanMode.shallow, false)) {
373                                iconTarFiles[size] ~= path.idup;
374                            }
375                        }
376                    }
377                }
378            }
379
380            immutable hres = dstore.getHints (pkid);
381            if (!hres.empty) {
382                synchronized (this) {
383                    if (firstHintEntry) {
384                        firstHintEntry = false;
385                        hintsFile ~= hres;
386                    } else {
387                        hintsFile ~= ",\n";
388                        hintsFile ~= hres;
389                    }
390                }
391            }
392        }
393
394        // create the icon tarballs
395        if (withIconTar) {
396            logInfo ("Creating icon tarball.");
397            foreach (size; iconTarSizes) {
398                import std.conv : to;
399
400                auto iconTar = scoped!ArchiveCompressor (ArchiveType.GZIP);
401                iconTar.open (buildPath (dataExportDir, format ("icons-%sx%s.tar.gz", size, size)));
402                auto iconFiles = iconTarFiles[size].data;
403                sort!("a < b", SwapStrategy.stable) (to!(string[]) (iconFiles));
404                foreach (fname; iconFiles) {
405                    iconTar.addFile (fname);
406                }
407            }
408            logInfo ("Icon tarball(s) built.");
409        }
410
411        string dataBaseFname;
412        if (conf.metadataType == DataType.XML)
413            dataBaseFname = buildPath (dataExportDir, format ("Components-%s.xml", arch));
414        else
415            dataBaseFname = buildPath (dataExportDir, format ("Components-%s.yml", arch));
416        immutable hintsBaseFname = buildPath (hintsExportDir, format ("Hints-%s.json", arch));
417
418        // write metadata
419        logInfo ("Writing metadata for %s/%s [%s]", suite.name, section, arch);
420
421        // add the closing XML tag for XML metadata
422        if (conf.metadataType == DataType.XML)
423            mdataFile ~= "</components>\n";
424
425        // compress metadata and save it to disk
426        auto mdataFileBytes = cast(ubyte[]) mdataFile.data;
427        compressAndSave (mdataFileBytes, dataBaseFname ~ ".gz", ArchiveType.GZIP);
428        compressAndSave (mdataFileBytes, dataBaseFname ~ ".xz", ArchiveType.XZ);
429
430        // write hints
431        logInfo ("Writing hints for %s/%s [%s]", suite.name, section, arch);
432
433        // finalize the JSON hints document
434        hintsFile ~= "\n]\n";
435
436        // compress hints
437        auto hintsFileBytes = cast(ubyte[]) hintsFile.data;
438        compressAndSave (hintsFileBytes, hintsBaseFname ~ ".gz", ArchiveType.GZIP);
439        compressAndSave (hintsFileBytes, hintsBaseFname ~ ".xz", ArchiveType.XZ);
440    }
441
442    private Package[string] getIconCandidatePackages (Suite suite, string section, string arch)
443    {
444        // always load the "main" and "universe" components, which contain most of the icon data
445        // on Debian and Ubuntu.
446        // FIXME: This is a hack, find a sane way to get rid of this, or at least get rid of the
447        // distro-specific hardcoding.
448        auto pkgs = appender!(Package[]);
449        foreach (ref newSection; ["main", "universe"]) {
450            if ((section != newSection) && (suite.sections.canFind (newSection))) {
451                pkgs ~= pkgIndex.packagesFor (suite.name, newSection, arch);
452                if (!suite.baseSuite.empty)
453                    pkgs ~= pkgIndex.packagesFor (suite.baseSuite, newSection, arch);
454            }
455        }
456        if (!suite.baseSuite.empty)
457            pkgs ~= pkgIndex.packagesFor (suite.baseSuite, section, arch);
458        pkgs ~= pkgIndex.packagesFor (suite.name, section, arch);
459
460        Package[string] pkgMap;
461        foreach (ref pkg; pkgs.data) {
462            immutable pkid = pkg.id;
463            pkgMap[pkid] = pkg;
464        }
465
466        return pkgMap;
467    }
468
469    void run (string suite_name)
470    {
471        Suite suite;
472        foreach (ref s; conf.suites)
473            if (s.name == suite_name)
474                suite = s;
475
476        if (suite.isImmutable) {
477            // we also can't process anything if there are no architectures defined
478            logError ("Suite '%s' is marked as immutable. No changes are allowed.", suite.name);
479            return;
480        }
481
482        if (suite.sections.empty) {
483            // if we have no sections, we can't do anything but exit...
484            logError ("Suite '%s' has no sections. Can not continue.", suite_name);
485            return;
486        }
487
488        if (suite.architectures.empty) {
489            // we also can't process anything if there are no architectures defined
490            logError ("Suite '%s' has no architectures defined. Can not continue.", suite.name);
491            return;
492        }
493
494        auto reportgen = new ReportGenerator (dstore);
495
496        auto dataChanged = false;
497        foreach (ref section; suite.sections) {
498            auto sectionPkgs = appender!(Package[]);
499            auto iconTarBuilt = false;
500            auto suiteDataChanged = false;
501            foreach (ref arch; suite.architectures) {
502                // update package contents information and flag boring packages as ignored
503                immutable foundInteresting = seedContentsData (suite, section, arch);
504
505                // check if the suite/section/arch has actually changed
506                if (!foundInteresting) {
507                    logInfo ("Skipping %s/%s [%s], no interesting new packages since last update.", suite.name, section, arch);
508                    continue;
509                }
510
511                // process new packages
512                auto pkgs = pkgIndex.packagesFor (suite.name, section, arch);
513                auto iconh = scoped!IconHandler (dstore.mediaExportPoolDir,
514                                              getIconCandidatePackages (suite, section, arch),
515                                              suite.iconTheme);
516                processPackages (pkgs, iconh);
517
518                // export package data
519                exportData (suite, section, arch, pkgs, !iconTarBuilt);
520                iconTarBuilt = true;
521                suiteDataChanged = true;
522
523                // we store the package info over all architectures to generate reports later
524                sectionPkgs ~= pkgs;
525
526                // log progress
527                logInfo ("Completed processing of %s/%s [%s]", suite.name, section, arch);
528            }
529
530            // write reports & statistics and render HTML, if that option is selected
531            if (suiteDataChanged) {
532                reportgen.processFor (suite.name, section, sectionPkgs.data);
533                dataChanged = true;
534            }
535
536            // do garbage collection run now.
537            // we might have allocated very big chunks of memory during this iteration,
538            // that we can (mostly) free now - on some machines, the GC runs too late,
539            // making the system run out of memory, which ultimately gets us OOM-killed.
540            // we don't like that, and give the GC a hint to do the right thing.
541            pkgIndex.release ();
542            gcCollect ();
543        }
544
545        // free some memory
546        pkgIndex.release ();
547        gcCollect ();
548
549        // render index pages & statistics
550        reportgen.updateIndexPages ();
551        if (dataChanged)
552            reportgen.exportStatistics ();
553    }
554
555    private void cleanupStatistics ()
556    {
557        import std.json;
558        import std.algorithm : sort;
559
560        auto allStats = dstore.getStatistics ();
561        sort!("a.time < b.time") (allStats);
562        string[string] lastJData;
563        size_t[string] lastTime;
564        foreach (ref entry; allStats) {
565            if (entry.data.type == JSON_TYPE.ARRAY) {
566                // we don't clean up combined statistics entries, and therefore need to reset
567                // the last-data hashmaps as soon as we encounter one to not loose data.
568                lastJData = null;
569                lastTime = null;
570                continue;
571            }
572
573            immutable ssid = format ("%s-%s", entry.data["suite"].str, entry.data["section"].str);
574            if (ssid !in lastJData) {
575                lastJData[ssid] = entry.data.toString;
576                lastTime[ssid]  = entry.time;
577                continue;
578            }
579
580            auto jdata = entry.data.toString;
581            if (lastJData[ssid] == jdata) {
582                logInfo ("Removing superfluous statistics entry: %s", lastTime[ssid]);
583                dstore.removeStatistics (lastTime[ssid]);
584            }
585
586            lastTime[ssid] = entry.time;
587            lastJData[ssid] = jdata;
588        }
589    }
590
591    void runCleanup ()
592    {
593        bool[string] pkgSet;
594
595        logInfo ("Cleaning up left over temporary data.");
596        immutable tmpDir = buildPath (conf.cacheRootDir, "tmp");
597        if (std.file.exists (tmpDir))
598            rmdirRecurse (tmpDir);
599
600        logInfo ("Collecting information.");
601        // build a set of all valid packages
602        foreach (ref suite; conf.suites) {
603            foreach (ref section; suite.sections) {
604                foreach (ref arch; parallel (suite.architectures)) {
605                    auto pkgs = pkgIndex.packagesFor (suite.name, section, arch);
606                    if (!suite.baseSuite.empty)
607                        pkgs ~= pkgIndex.packagesFor (suite.baseSuite, section, arch);
608                    synchronized (this) {
609                        foreach (ref pkg; pkgs) {
610                            pkgSet[pkg.id] = true;
611                        }
612                    }
613                }
614
615                // free some memory
616                pkgIndex.release ();
617                gcCollect ();
618            }
619        }
620
621        // release index resources
622        pkgIndex.release ();
623
624        logInfo ("Cleaning up superseded data.");
625
626        // remove packages from the caches which are no longer in the archive
627        pkgSet.rehash;
628        cstore.removePackagesNotInSet (pkgSet);
629        dstore.removePackagesNotInSet (pkgSet);
630
631        // enforce another GC cycle to free memory
632        gcCollect ();
633
634        // remove orphaned data and media
635        logInfo ("Cleaning up obsolete media.");
636        dstore.cleanupCruft ();
637
638        // cleanup duplicate statistical entries
639        logInfo ("Cleaning up excess statistical data.");
640        cleanupStatistics ();
641    }
642
643    /**
644     * Drop all packages which contain valid components or hints
645     * from the database.
646     * This is useful when big generator changes have been done, which
647     * require reprocessing of all components.
648     */
649    void removeHintsComponents (string suite_name)
650    {
651        Suite suite;
652        foreach (ref s; conf.suites)
653            if (s.name == suite_name)
654                suite = s;
655
656        foreach (ref section; suite.sections) {
657            foreach (ref arch; parallel (suite.architectures)) {
658                auto pkgs = pkgIndex.packagesFor (suite.name, section, arch);
659
660                foreach (ref pkg; pkgs) {
661                    auto pkid = pkg.id;
662
663                    if (!dstore.packageExists (pkid))
664                        continue;
665                    if (dstore.isIgnored (pkid))
666                        continue;
667
668                    dstore.removePackage (pkid);
669                }
670            }
671        }
672
673        dstore.cleanupCruft ();
674    }
675
676    void forgetPackage (string identifier)
677    {
678        if (identifier.count ("/") == 3) {
679            // we have a package-id, so we can do a targeted remove
680            immutable pkid = identifier;
681            logDebug ("Considering %s to be a package-id.", pkid);
682
683            if (cstore.packageExists (pkid))
684                cstore.removePackage (pkid);
685            if (dstore.packageExists (pkid))
686                dstore.removePackage (pkid);
687            logInfo ("Removed package with ID: %s", pkid);
688        } else {
689            auto pkids = dstore.getPkidsMatching (identifier);
690            foreach (ref pkid; pkids) {
691                dstore.removePackage (pkid);
692                if (cstore.packageExists (pkid))
693                    cstore.removePackage (pkid);
694                logInfo ("Removed package with ID: %s", pkid);
695            }
696        }
697
698        // remove orphaned data and media
699        dstore.cleanupCruft ();
700    }
701}
Note: See TracBrowser for help on using the repository browser.