2017-02-16 03:27:16 +00:00
|
|
|
/* jslint node: true */
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode;
|
|
|
|
const ExitCodes = require('./oputil_common.js').ExitCodes;
|
|
|
|
const argv = require('./oputil_common.js').argv;
|
|
|
|
const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases;
|
|
|
|
const getHelpFor = require('./oputil_help.js').getHelpFor;
|
2018-03-10 18:37:23 +00:00
|
|
|
const {
|
2018-06-22 05:15:04 +00:00
|
|
|
getAreaAndStorage,
|
2018-12-04 06:51:43 +00:00
|
|
|
looksLikePattern,
|
|
|
|
getConfigPath,
|
|
|
|
getAnswers,
|
|
|
|
writeConfig
|
2018-03-10 18:37:23 +00:00
|
|
|
} = require('./oputil_common.js');
|
2018-01-01 00:54:11 +00:00
|
|
|
const Errors = require('../enig_error.js').Errors;
|
2017-02-16 03:27:16 +00:00
|
|
|
|
|
|
|
const async = require('async');
|
2017-05-20 03:20:19 +00:00
|
|
|
const fs = require('graceful-fs');
|
2017-02-16 03:27:16 +00:00
|
|
|
const paths = require('path');
|
2017-02-19 02:00:09 +00:00
|
|
|
const _ = require('lodash');
|
|
|
|
const moment = require('moment');
|
2017-02-25 06:39:31 +00:00
|
|
|
const inq = require('inquirer');
|
2018-03-10 18:37:23 +00:00
|
|
|
const glob = require('glob');
|
2018-12-04 06:51:43 +00:00
|
|
|
const sanatizeFilename = require('sanitize-filename');
|
|
|
|
const hjson = require('hjson');
|
|
|
|
const { mkdirs } = require('fs-extra');
|
2017-02-16 03:27:16 +00:00
|
|
|
|
|
|
|
exports.handleFileBaseCommand = handleFileBaseCommand;
|
|
|
|
|
|
|
|
/*
|
|
|
|
:TODO:
|
|
|
|
|
|
|
|
Global options:
|
|
|
|
--yes: assume yes
|
2018-02-17 06:00:15 +00:00
|
|
|
--no-prompt: try to avoid user input
|
2017-02-16 03:27:16 +00:00
|
|
|
|
|
|
|
Prompt for import and description before scan
|
|
|
|
* Only after finding duplicate-by-path
|
|
|
|
* Default to filename -> desc if auto import
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
let fileArea; // required during init
|
|
|
|
|
2017-09-09 05:11:01 +00:00
|
|
|
function finalizeEntryAndPersist(isUpdate, fileEntry, descHandler, cb) {
|
2018-06-22 05:15:04 +00:00
|
|
|
async.series(
|
|
|
|
[
|
|
|
|
function getDescFromHandlerIfNeeded(callback) {
|
2018-12-17 19:08:06 +00:00
|
|
|
if((fileEntry.desc && fileEntry.descSrc != 'fileName' && fileEntry.desc.length > 0 ) && !argv['desc-file']) {
|
2018-06-22 05:15:04 +00:00
|
|
|
return callback(null); // we have a desc already and are NOT overriding with desc file
|
|
|
|
}
|
|
|
|
|
|
|
|
if(!descHandler) {
|
|
|
|
return callback(null); // not much we can do!
|
|
|
|
}
|
|
|
|
|
|
|
|
const desc = descHandler.getDescription(fileEntry.fileName);
|
|
|
|
if(desc) {
|
|
|
|
fileEntry.desc = desc;
|
|
|
|
}
|
|
|
|
return callback(null);
|
|
|
|
},
|
|
|
|
function getDescFromUserIfNeeded(callback) {
|
|
|
|
if(fileEntry.desc && fileEntry.desc.length > 0 ) {
|
|
|
|
return callback(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName;
|
|
|
|
const descFromFile = getDescFromFileName(fileEntry.fileName);
|
|
|
|
|
|
|
|
if(false === argv.prompt) {
|
|
|
|
fileEntry.desc = descFromFile;
|
|
|
|
return callback(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
const questions = [
|
|
|
|
{
|
|
|
|
name : 'desc',
|
|
|
|
message : `Description for ${fileEntry.fileName}:`,
|
|
|
|
type : 'input',
|
|
|
|
default : descFromFile,
|
|
|
|
}
|
|
|
|
];
|
|
|
|
|
|
|
|
inq.prompt(questions).then( answers => {
|
|
|
|
fileEntry.desc = answers.desc;
|
|
|
|
return callback(null);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function persist(callback) {
|
|
|
|
fileEntry.persist(isUpdate, err => {
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
);
|
2017-02-25 06:39:31 +00:00
|
|
|
}
|
|
|
|
|
2018-12-17 19:08:06 +00:00
|
|
|
const SCAN_EXCLUDE_FILENAMES = [
|
|
|
|
'DESCRIPT.ION',
|
|
|
|
'FILES.BBS',
|
|
|
|
'ALLFILES.TXT',
|
|
|
|
];
|
2017-08-25 02:22:50 +00:00
|
|
|
|
|
|
|
function loadDescHandler(path, cb) {
|
2018-12-17 19:08:06 +00:00
|
|
|
const handlerClassFromFileName = {
|
|
|
|
'descript.ion' : require('../../core/descript_ion_file.js'),
|
|
|
|
'files.bbs' : require('../../core/files_bbs_file.js'),
|
|
|
|
}[paths.basename(path).toLowerCase()];
|
2017-08-25 02:22:50 +00:00
|
|
|
|
2018-12-17 19:08:06 +00:00
|
|
|
if(!handlerClassFromFileName) {
|
|
|
|
return cb(Errors.DoesNotExist(`No handlers registered for ${paths.basename(path)}`));
|
|
|
|
}
|
2017-08-25 02:22:50 +00:00
|
|
|
|
2018-12-17 19:08:06 +00:00
|
|
|
handlerClassFromFileName.createFromFile(path, (err, descHandler) => {
|
2018-06-22 05:15:04 +00:00
|
|
|
return cb(err, descHandler);
|
|
|
|
});
|
2017-08-25 02:22:50 +00:00
|
|
|
}
|
|
|
|
|
2018-12-17 19:08:06 +00:00
|
|
|
//
|
|
|
|
// Try to find a suitable description handler by
|
|
|
|
// checking for common filenames.
|
|
|
|
//
|
|
|
|
function findSuitableDescHandler(basePath, cb) {
|
|
|
|
const commonFiles = [ 'FILES.BBS', 'DESCRIPT.ION' ];
|
|
|
|
|
|
|
|
async.eachSeries(commonFiles, (fileName, nextFileName) => {
|
|
|
|
loadDescHandler(paths.join(basePath, fileName), (err, handler) => {
|
|
|
|
if(!err && handler) {
|
|
|
|
return cb(null, handler);
|
|
|
|
}
|
|
|
|
return nextFileName(null);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
() => {
|
|
|
|
return cb(Errors.DoesNotExist('No suitable description handler available'));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-02-16 03:27:16 +00:00
|
|
|
function scanFileAreaForChanges(areaInfo, options, cb) {
|
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
const storageLocations = fileArea.getAreaStorageLocations(areaInfo).filter(sl => {
|
|
|
|
return options.areaAndStorageInfo.find(asi => {
|
|
|
|
return !asi.storageTag || sl.storageTag === asi.storageTag;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
function updateTags(fe) {
|
|
|
|
if(Array.isArray(options.tags)) {
|
|
|
|
fe.hashTags = new Set(options.tags);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const FileEntry = require('../file_entry.js');
|
|
|
|
|
|
|
|
const readDir = options.glob ?
|
|
|
|
(dir, next) => {
|
|
|
|
return glob(options.glob, { cwd : dir, nodir : true }, next);
|
|
|
|
} :
|
|
|
|
(dir, next) => {
|
|
|
|
return fs.readdir(dir, next);
|
|
|
|
};
|
|
|
|
|
|
|
|
async.eachSeries(storageLocations, (storageLoc, nextLocation) => {
|
|
|
|
async.waterfall(
|
|
|
|
[
|
|
|
|
function initDescFile(callback) {
|
|
|
|
if(options.descFileHandler) {
|
|
|
|
return callback(null, options.descFileHandler); // we're going to use the global handler
|
|
|
|
}
|
|
|
|
|
2018-12-17 19:08:06 +00:00
|
|
|
findSuitableDescHandler(storageLoc.dir, (err, descHandler) => {
|
2018-06-22 05:15:04 +00:00
|
|
|
return callback(null, descHandler);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function scanPhysFiles(descHandler, callback) {
|
|
|
|
const physDir = storageLoc.dir;
|
|
|
|
|
|
|
|
readDir(physDir, (err, files) => {
|
|
|
|
if(err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
async.eachSeries(files, (fileName, nextFile) => {
|
|
|
|
const fullPath = paths.join(physDir, fileName);
|
|
|
|
|
|
|
|
if(SCAN_EXCLUDE_FILENAMES.includes(fileName.toUpperCase())) {
|
|
|
|
console.info(`Excluding ${fullPath}`);
|
|
|
|
return nextFile(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
fs.stat(fullPath, (err, stats) => {
|
|
|
|
if(err) {
|
|
|
|
// :TODO: Log me!
|
|
|
|
return nextFile(null); // always try next file
|
|
|
|
}
|
|
|
|
|
|
|
|
if(!stats.isFile()) {
|
|
|
|
return nextFile(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
process.stdout.write(`Scanning ${fullPath}... `);
|
|
|
|
|
|
|
|
async.series(
|
|
|
|
[
|
|
|
|
function quickCheck(next) {
|
|
|
|
if(!options.quick) {
|
|
|
|
return next(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
FileEntry.quickCheckExistsByPath(fullPath, (err, exists) => {
|
|
|
|
if(exists) {
|
|
|
|
console.info('Dupe');
|
|
|
|
return nextFile(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
return next(null);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function fullScan() {
|
|
|
|
fileArea.scanFile(
|
|
|
|
fullPath,
|
|
|
|
{
|
|
|
|
areaTag : areaInfo.areaTag,
|
|
|
|
storageTag : storageLoc.storageTag
|
|
|
|
},
|
|
|
|
(err, fileEntry, dupeEntries) => {
|
|
|
|
if(err) {
|
|
|
|
console.info(`Error: ${err.message}`);
|
|
|
|
return nextFile(null); // try next anyway
|
|
|
|
}
|
|
|
|
|
|
|
|
//
|
|
|
|
// We'll update the entry if the following conditions are met:
|
|
|
|
// * We have a single duplicate, and:
|
|
|
|
// * --update was passed or the existing entry's desc,
|
|
|
|
// longDesc, or est_release_year meta are blank/empty
|
|
|
|
//
|
|
|
|
if(argv.update && 1 === dupeEntries.length) {
|
|
|
|
const FileEntry = require('../../core/file_entry.js');
|
|
|
|
const existingEntry = new FileEntry();
|
|
|
|
|
|
|
|
return existingEntry.load(dupeEntries[0].fileId, err => {
|
|
|
|
if(err) {
|
|
|
|
console.info('Dupe (cannot update)');
|
|
|
|
return nextFile(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
//
|
|
|
|
// Update only if tags or desc changed
|
|
|
|
//
|
|
|
|
const optTags = Array.isArray(options.tags) ? new Set(options.tags) : existingEntry.hashTags;
|
|
|
|
const tagsEq = _.isEqual(optTags, existingEntry.hashTags);
|
|
|
|
|
2018-12-29 20:28:08 +00:00
|
|
|
let descSauceCompare;
|
|
|
|
if(existingEntry.meta.desc_sauce) {
|
|
|
|
descSauceCompare = JSON.stringify(existingEntry.meta.desc_sauce);
|
|
|
|
}
|
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
if( tagsEq &&
|
2018-02-17 06:00:15 +00:00
|
|
|
fileEntry.desc === existingEntry.desc &&
|
2018-12-29 20:15:58 +00:00
|
|
|
fileEntry.descLong === existingEntry.descLong &&
|
|
|
|
fileEntry.meta.est_release_year === existingEntry.meta.est_release_year &&
|
2018-12-29 20:28:08 +00:00
|
|
|
fileEntry.meta.desc_sauce === descSauceCompare
|
2018-12-29 20:15:58 +00:00
|
|
|
)
|
2018-06-22 05:15:04 +00:00
|
|
|
{
|
|
|
|
console.info('Dupe');
|
|
|
|
return nextFile(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
console.info('Dupe (updating)');
|
|
|
|
|
|
|
|
// don't allow overwrite of values if new version is blank
|
|
|
|
existingEntry.desc = fileEntry.desc || existingEntry.desc;
|
|
|
|
existingEntry.descLong = fileEntry.descLong || existingEntry.descLong;
|
|
|
|
|
|
|
|
if(fileEntry.meta.est_release_year) {
|
|
|
|
existingEntry.meta.est_release_year = fileEntry.meta.est_release_year;
|
|
|
|
}
|
|
|
|
|
2018-12-29 20:15:58 +00:00
|
|
|
if(fileEntry.meta.desc_sauce) {
|
|
|
|
existingEntry.meta.desc_sauce = fileEntry.meta.desc_sauce;
|
|
|
|
}
|
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
updateTags(existingEntry);
|
|
|
|
|
|
|
|
finalizeEntryAndPersist(true, existingEntry, descHandler, err => {
|
|
|
|
return nextFile(err);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
} else if(dupeEntries.length > 0) {
|
|
|
|
console.info('Dupe');
|
|
|
|
return nextFile(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
console.info('Done!');
|
|
|
|
updateTags(fileEntry);
|
|
|
|
|
|
|
|
finalizeEntryAndPersist(false, fileEntry, descHandler, err => {
|
|
|
|
return nextFile(err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
]
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}, err => {
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function scanDbEntries(callback) {
|
|
|
|
// :TODO: Look @ db entries for area that were *not* processed above
|
|
|
|
return callback(null);
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
return nextLocation(err);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
},
|
|
|
|
err => {
|
|
|
|
return cb(err);
|
|
|
|
});
|
2017-02-16 03:27:16 +00:00
|
|
|
}
|
|
|
|
|
2017-02-19 02:00:09 +00:00
|
|
|
function dumpAreaInfo(areaInfo, areaAndStorageInfo, cb) {
|
2018-06-22 05:15:04 +00:00
|
|
|
console.info(`areaTag: ${areaInfo.areaTag}`);
|
|
|
|
console.info(`name: ${areaInfo.name}`);
|
|
|
|
console.info(`desc: ${areaInfo.desc}`);
|
2017-02-19 02:00:09 +00:00
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
areaInfo.storage.forEach(si => {
|
|
|
|
console.info(`storageTag: ${si.storageTag} => ${si.dir}`);
|
|
|
|
});
|
|
|
|
console.info('');
|
2018-02-17 06:00:15 +00:00
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
return cb(null);
|
2017-02-19 02:00:09 +00:00
|
|
|
}
|
|
|
|
|
2017-05-24 03:55:22 +00:00
|
|
|
function getFileEntries(pattern, cb) {
|
2018-06-22 05:15:04 +00:00
|
|
|
// spec: FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA
|
|
|
|
const FileEntry = require('../../core/file_entry.js');
|
|
|
|
|
|
|
|
async.waterfall(
|
|
|
|
[
|
|
|
|
function tryByFileId(callback) {
|
|
|
|
const fileId = parseInt(pattern);
|
|
|
|
if(!/^[0-9]+$/.test(pattern) || isNaN(fileId)) {
|
|
|
|
return callback(null, null); // try SHA
|
|
|
|
}
|
|
|
|
|
|
|
|
const fileEntry = new FileEntry();
|
|
|
|
fileEntry.load(fileId, err => {
|
|
|
|
return callback(null, err ? null : [ fileEntry ] );
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function tryByShaOrPartialSha(entries, callback) {
|
|
|
|
if(entries) {
|
|
|
|
return callback(null, entries); // already got it by FILE_ID
|
|
|
|
}
|
|
|
|
|
2018-11-04 22:01:27 +00:00
|
|
|
FileEntry.findBySha(pattern, (err, fileEntry) => {
|
2018-06-22 05:15:04 +00:00
|
|
|
return callback(null, fileEntry ? [ fileEntry ] : null );
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function tryByFileNameWildcard(entries, callback) {
|
|
|
|
if(entries) {
|
|
|
|
return callback(null, entries); // already got by FILE_ID|SHA
|
|
|
|
}
|
|
|
|
|
|
|
|
return FileEntry.findByFileNameWildcard(pattern, callback);
|
|
|
|
}
|
|
|
|
],
|
|
|
|
(err, entries) => {
|
|
|
|
return cb(err, entries);
|
|
|
|
}
|
|
|
|
);
|
2017-02-21 05:31:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function dumpFileInfo(shaOrFileId, cb) {
|
2018-06-22 05:15:04 +00:00
|
|
|
async.waterfall(
|
|
|
|
[
|
|
|
|
function getEntry(callback) {
|
|
|
|
getFileEntries(shaOrFileId, (err, entries) => {
|
|
|
|
if(err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
return callback(null, entries[0]);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function dumpInfo(fileEntry, callback) {
|
|
|
|
const fullPath = paths.join(fileArea.getAreaStorageDirectoryByTag(fileEntry.storageTag), fileEntry.fileName);
|
|
|
|
|
|
|
|
console.info(`file_id: ${fileEntry.fileId}`);
|
|
|
|
console.info(`sha_256: ${fileEntry.fileSha256}`);
|
|
|
|
console.info(`area_tag: ${fileEntry.areaTag}`);
|
|
|
|
console.info(`storage_tag: ${fileEntry.storageTag}`);
|
|
|
|
console.info(`path: ${fullPath}`);
|
|
|
|
console.info(`hashTags: ${Array.from(fileEntry.hashTags).join(', ')}`);
|
|
|
|
console.info(`uploaded: ${moment(fileEntry.uploadTimestamp).format()}`);
|
|
|
|
|
|
|
|
_.each(fileEntry.meta, (metaValue, metaName) => {
|
|
|
|
console.info(`${metaName}: ${metaValue}`);
|
|
|
|
});
|
|
|
|
|
|
|
|
if(argv['show-desc']) {
|
|
|
|
console.info(`${fileEntry.desc}`);
|
|
|
|
}
|
|
|
|
console.info('');
|
|
|
|
|
|
|
|
return callback(null);
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
);
|
2017-02-19 02:00:09 +00:00
|
|
|
}
|
|
|
|
|
2018-12-21 21:39:57 +00:00
|
|
|
function displayFileOrAreaInfo() {
|
2018-06-22 05:15:04 +00:00
|
|
|
// AREA_TAG[@STORAGE_TAG]
|
2018-12-21 21:39:57 +00:00
|
|
|
// SHA256|PARTIAL|FILE_ID|FILENAME_WILDCARD
|
2018-06-22 05:15:04 +00:00
|
|
|
// if sha: dump file info
|
2018-12-21 21:39:57 +00:00
|
|
|
// if area/storage dump area(s) +
|
2018-06-22 05:15:04 +00:00
|
|
|
|
|
|
|
async.series(
|
|
|
|
[
|
|
|
|
function init(callback) {
|
|
|
|
return initConfigAndDatabases(callback);
|
|
|
|
},
|
|
|
|
function dumpInfo(callback) {
|
|
|
|
const sysConfig = require('../../core/config.js').get();
|
|
|
|
let suppliedAreas = argv._.slice(2);
|
|
|
|
if(!suppliedAreas || 0 === suppliedAreas.length) {
|
|
|
|
suppliedAreas = _.map(sysConfig.fileBase.areas, (areaInfo, areaTag) => areaTag);
|
|
|
|
}
|
|
|
|
|
|
|
|
const areaAndStorageInfo = getAreaAndStorage(suppliedAreas);
|
|
|
|
|
|
|
|
fileArea = require('../../core/file_base_area.js');
|
|
|
|
|
|
|
|
async.eachSeries(areaAndStorageInfo, (areaAndStorage, nextArea) => {
|
|
|
|
const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag);
|
|
|
|
if(areaInfo) {
|
|
|
|
return dumpAreaInfo(areaInfo, areaAndStorageInfo, nextArea);
|
|
|
|
} else {
|
|
|
|
return dumpFileInfo(areaAndStorage.areaTag, nextArea);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
err => {
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
if(err) {
|
|
|
|
process.exitCode = ExitCodes.ERROR;
|
|
|
|
console.error(err.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
2017-02-19 02:00:09 +00:00
|
|
|
}
|
|
|
|
|
2017-02-16 03:27:16 +00:00
|
|
|
function scanFileAreas() {
|
2018-06-22 05:15:04 +00:00
|
|
|
const options = {};
|
|
|
|
|
|
|
|
const tags = argv.tags;
|
|
|
|
if(tags) {
|
|
|
|
options.tags = tags.split(',');
|
|
|
|
}
|
|
|
|
|
|
|
|
options.descFile = argv['desc-file']; // --desc-file or --desc-file PATH
|
|
|
|
options.quick = argv.quick;
|
|
|
|
|
|
|
|
options.areaAndStorageInfo = getAreaAndStorage(argv._.slice(2));
|
|
|
|
|
|
|
|
const last = argv._[argv._.length - 1];
|
|
|
|
if(options.areaAndStorageInfo.length > 1 && looksLikePattern(last)) {
|
|
|
|
options.glob = last;
|
|
|
|
options.areaAndStorageInfo.length -= 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
async.series(
|
|
|
|
[
|
|
|
|
function init(callback) {
|
|
|
|
return initConfigAndDatabases(callback);
|
|
|
|
},
|
|
|
|
function initMime(callback) {
|
|
|
|
return require('../../core/mime_util.js').startup(callback);
|
|
|
|
},
|
|
|
|
function initGlobalDescHandler(callback) {
|
|
|
|
//
|
|
|
|
// If options.descFile is a String, it represents a FILE|PATH. We'll init
|
|
|
|
// the description handler now. Else, we'll attempt to look for a description
|
|
|
|
// file in each storage location.
|
|
|
|
//
|
|
|
|
if(!_.isString(options.descFile)) {
|
|
|
|
return callback(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
loadDescHandler(options.descFile, (err, descHandler) => {
|
|
|
|
options.descFileHandler = descHandler;
|
|
|
|
return callback(null);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function scanAreas(callback) {
|
|
|
|
fileArea = require('../../core/file_base_area.js');
|
|
|
|
|
|
|
|
async.eachSeries(options.areaAndStorageInfo, (areaAndStorage, nextAreaTag) => {
|
|
|
|
const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag);
|
|
|
|
if(!areaInfo) {
|
|
|
|
return nextAreaTag(new Error(`Invalid file base area tag: ${areaAndStorage.areaTag}`));
|
|
|
|
}
|
|
|
|
|
|
|
|
console.info(`Processing area "${areaInfo.name}":`);
|
|
|
|
|
|
|
|
scanFileAreaForChanges(areaInfo, options, err => {
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
}, err => {
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
if(err) {
|
|
|
|
process.exitCode = ExitCodes.ERROR;
|
|
|
|
console.error(err.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
2017-02-16 03:27:16 +00:00
|
|
|
}
|
|
|
|
|
2017-09-24 05:03:21 +00:00
|
|
|
function expandFileTargets(targets, cb) {
|
2018-06-22 05:15:04 +00:00
|
|
|
let entries = [];
|
|
|
|
|
|
|
|
// Each entry may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG]
|
|
|
|
const FileEntry = require('../../core/file_entry.js');
|
|
|
|
|
|
|
|
async.eachSeries(targets, (areaAndStorage, next) => {
|
|
|
|
const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag);
|
|
|
|
|
|
|
|
if(areaInfo) {
|
|
|
|
// AREA_TAG[@STORAGE_TAG] - all files in area@tag
|
|
|
|
const findFilter = {
|
|
|
|
areaTag : areaAndStorage.areaTag,
|
|
|
|
};
|
|
|
|
|
|
|
|
if(areaAndStorage.storageTag) {
|
|
|
|
findFilter.storageTag = areaAndStorage.storageTag;
|
|
|
|
}
|
|
|
|
|
|
|
|
FileEntry.findFiles(findFilter, (err, fileIds) => {
|
|
|
|
if(err) {
|
|
|
|
return next(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
async.each(fileIds, (fileId, nextFileId) => {
|
|
|
|
const fileEntry = new FileEntry();
|
|
|
|
fileEntry.load(fileId, err => {
|
|
|
|
if(!err) {
|
|
|
|
entries.push(fileEntry);
|
|
|
|
}
|
|
|
|
return nextFileId(err);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
err => {
|
|
|
|
return next(err);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
} else {
|
|
|
|
// FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA
|
|
|
|
// :TODO: FULL_PATH -> entries
|
|
|
|
getFileEntries(areaAndStorage.pattern, (err, fileEntries) => {
|
|
|
|
if(err) {
|
|
|
|
return next(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
entries = entries.concat(fileEntries);
|
|
|
|
return next(null);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
err => {
|
|
|
|
return cb(err, entries);
|
|
|
|
});
|
2017-09-24 05:03:21 +00:00
|
|
|
}
|
|
|
|
|
2017-02-21 05:31:01 +00:00
|
|
|
function moveFiles() {
|
2018-06-22 05:15:04 +00:00
|
|
|
//
|
|
|
|
// oputil fb move SRC [SRC2 ...] DST
|
|
|
|
//
|
|
|
|
// SRC: FILENAME_WC|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG]
|
|
|
|
// DST: AREA_TAG[@STORAGE_TAG]
|
|
|
|
//
|
|
|
|
if(argv._.length < 4) {
|
|
|
|
return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR);
|
|
|
|
}
|
|
|
|
|
|
|
|
const moveArgs = argv._.slice(2);
|
|
|
|
const src = getAreaAndStorage(moveArgs.slice(0, -1));
|
|
|
|
const dst = getAreaAndStorage(moveArgs.slice(-1))[0];
|
|
|
|
|
|
|
|
let FileEntry;
|
|
|
|
|
|
|
|
async.waterfall(
|
|
|
|
[
|
|
|
|
function init(callback) {
|
|
|
|
return initConfigAndDatabases( err => {
|
|
|
|
if(!err) {
|
|
|
|
fileArea = require('../../core/file_base_area.js');
|
|
|
|
}
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function validateAndExpandSourceAndDest(callback) {
|
|
|
|
const areaInfo = fileArea.getFileAreaByTag(dst.areaTag);
|
|
|
|
if(areaInfo) {
|
|
|
|
dst.areaInfo = areaInfo;
|
|
|
|
} else {
|
|
|
|
return callback(Errors.DoesNotExist('Invalid or unknown destination area'));
|
|
|
|
}
|
|
|
|
|
|
|
|
FileEntry = require('../../core/file_entry.js');
|
|
|
|
|
|
|
|
expandFileTargets(src, (err, srcEntries) => {
|
|
|
|
return callback(err, srcEntries);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function moveEntries(srcEntries, callback) {
|
|
|
|
|
|
|
|
if(!dst.storageTag) {
|
|
|
|
dst.storageTag = dst.areaInfo.storageTags[0];
|
|
|
|
}
|
|
|
|
|
|
|
|
const destDir = FileEntry.getAreaStorageDirectoryByTag(dst.storageTag);
|
|
|
|
|
|
|
|
async.eachSeries(srcEntries, (entry, nextEntry) => {
|
|
|
|
const srcPath = entry.filePath;
|
|
|
|
const dstPath = paths.join(destDir, entry.fileName);
|
|
|
|
|
|
|
|
process.stdout.write(`Moving ${srcPath} => ${dstPath}... `);
|
|
|
|
|
|
|
|
FileEntry.moveEntry(entry, dst.areaTag, dst.storageTag, err => {
|
|
|
|
if(err) {
|
|
|
|
console.info(`Failed: ${err.message}`);
|
|
|
|
} else {
|
|
|
|
console.info('Done');
|
|
|
|
}
|
|
|
|
return nextEntry(null); // always try next
|
|
|
|
});
|
|
|
|
},
|
|
|
|
err => {
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
if(err) {
|
|
|
|
process.exitCode = ExitCodes.ERROR;
|
|
|
|
console.error(err.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
2017-02-21 05:31:01 +00:00
|
|
|
}
|
|
|
|
|
2017-05-24 03:55:22 +00:00
|
|
|
function removeFiles() {
|
2018-06-22 05:15:04 +00:00
|
|
|
//
|
|
|
|
// oputil fb rm|remove|del|delete SRC [SRC2 ...]
|
|
|
|
//
|
|
|
|
// SRC: FILENAME_WC|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG]
|
|
|
|
//
|
|
|
|
// AREA_TAG[@STORAGE_TAG] remove all entries matching
|
|
|
|
// supplied area/storage tags
|
|
|
|
//
|
|
|
|
// --phys-file removes backing physical file(s)
|
|
|
|
//
|
|
|
|
if(argv._.length < 3) {
|
|
|
|
return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR);
|
|
|
|
}
|
|
|
|
|
|
|
|
const removePhysFile = argv['phys-file'];
|
|
|
|
|
|
|
|
const src = getAreaAndStorage(argv._.slice(2));
|
|
|
|
|
|
|
|
async.waterfall(
|
|
|
|
[
|
|
|
|
function init(callback) {
|
|
|
|
return initConfigAndDatabases( err => {
|
|
|
|
if(!err) {
|
|
|
|
fileArea = require('../../core/file_base_area.js');
|
|
|
|
}
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function expandSources(callback) {
|
|
|
|
expandFileTargets(src, (err, srcEntries) => {
|
|
|
|
return callback(err, srcEntries);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
function removeEntries(srcEntries, callback) {
|
|
|
|
const FileEntry = require('../../core/file_entry.js');
|
|
|
|
|
|
|
|
const extraOutput = removePhysFile ? ' (including physical file)' : '';
|
|
|
|
|
|
|
|
async.eachSeries(srcEntries, (entry, nextEntry) => {
|
|
|
|
|
|
|
|
process.stdout.write(`Removing ${entry.filePath}${extraOutput}... `);
|
|
|
|
|
|
|
|
FileEntry.removeEntry(entry, { removePhysFile }, err => {
|
|
|
|
if(err) {
|
|
|
|
console.info(`Failed: ${err.message}`);
|
|
|
|
} else {
|
|
|
|
console.info('Done');
|
|
|
|
}
|
|
|
|
|
|
|
|
return nextEntry(err);
|
|
|
|
});
|
|
|
|
}, err => {
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
if(err) {
|
|
|
|
process.exitCode = ExitCodes.ERROR;
|
|
|
|
console.error(err.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
2017-05-24 03:55:22 +00:00
|
|
|
}
|
|
|
|
|
2018-12-04 06:51:43 +00:00
|
|
|
function getFileBaseImportType(path) {
|
|
|
|
if(argv.type) {
|
|
|
|
return argv.type.toLowerCase();
|
|
|
|
}
|
|
|
|
|
|
|
|
return paths.extname(path).substr(1).toLowerCase(); // zxx, ...
|
|
|
|
}
|
|
|
|
|
|
|
|
function importFileAreas() {
|
|
|
|
//
|
|
|
|
// FILEGATE.ZXX "RAID" format currently the only supported format.
|
|
|
|
//
|
|
|
|
// See http://www.filegate.net/info/filegate.zxx
|
2018-12-05 03:42:56 +00:00
|
|
|
// ...same format as FILEBONE.NA:
|
|
|
|
// http://wiki.mysticbbs.com/doku.php?id=mutil_import_filebone_na
|
2018-12-04 06:51:43 +00:00
|
|
|
//
|
|
|
|
const importPath = argv._[argv._.length - 1];
|
|
|
|
if(argv._.length < 3 || !importPath || 0 === importPath.length) {
|
|
|
|
return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR);
|
|
|
|
}
|
|
|
|
|
|
|
|
const importType = getFileBaseImportType(importPath);
|
2018-12-05 03:42:56 +00:00
|
|
|
if(!['zxx', 'na'].includes(importType)) {
|
2018-12-04 06:51:43 +00:00
|
|
|
return console.error(`"${importType}" is not a recognized import file type`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const createDirs = argv['create-dirs'];
|
|
|
|
// :TODO: --base-dir (override config base/relative dir; use full paths)
|
|
|
|
|
|
|
|
async.waterfall(
|
|
|
|
[
|
|
|
|
(callback) => {
|
|
|
|
fs.readFile(importPath, 'utf8', (err, importData) => {
|
|
|
|
if(err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
const importInfo = {
|
|
|
|
storageTags : {},
|
|
|
|
areas : {},
|
|
|
|
count : 0,
|
|
|
|
};
|
|
|
|
|
|
|
|
const re = /Area\s+([^\s]+)\s+[0-9]\s+(?:!|\*&)\s+([^\r\n]+)/gm;
|
|
|
|
let m;
|
|
|
|
while((m = re.exec(importData))) {
|
|
|
|
const dir = m[1].trim();
|
|
|
|
const name = m[2].trim();
|
|
|
|
const safeName = sanatizeFilename(name);
|
|
|
|
|
|
|
|
const stPrefix = _.snakeCase(sanatizeFilename(safeName));
|
|
|
|
const storageTag = `${stPrefix}__${_.snakeCase(sanatizeFilename(dir))}`;
|
|
|
|
const areaTag = _.snakeCase(safeName);
|
|
|
|
|
|
|
|
if(!dir || !name || !storageTag || !areaTag) {
|
|
|
|
console.info(`Skipping entry: ${m[0]}`);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
importInfo.storageTags[storageTag] = dir;
|
|
|
|
importInfo.areas[areaTag] = {
|
|
|
|
name : name,
|
|
|
|
desc : name,
|
|
|
|
storageTags : [ storageTag ],
|
|
|
|
};
|
|
|
|
++importInfo.count;
|
|
|
|
}
|
|
|
|
|
|
|
|
if(0 === importInfo.count) {
|
|
|
|
return callback(new Error('Nothing to import'));
|
|
|
|
}
|
|
|
|
|
|
|
|
return callback(null, importInfo);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
(importInfo, callback) => {
|
|
|
|
return initConfigAndDatabases(err => {
|
|
|
|
return callback(err, importInfo);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
(importInfo, callback) => {
|
|
|
|
console.info(`Read to import the following ${importInfo.count} areas:`);
|
|
|
|
console.info('');
|
|
|
|
_.each(importInfo.areas, (area, areaTag) => {
|
|
|
|
console.info(`${area.name} (${areaTag}):`);
|
|
|
|
const dir = importInfo.storageTags[area.storageTags[0]];
|
|
|
|
console.info(` storage: ${area.storageTags[0]} => ${dir}`);
|
|
|
|
});
|
|
|
|
|
|
|
|
getAnswers([
|
|
|
|
{
|
|
|
|
name : 'proceed',
|
|
|
|
message : 'Proceed?',
|
|
|
|
type : 'confirm',
|
|
|
|
}
|
|
|
|
],
|
|
|
|
answers => {
|
|
|
|
if(answers.proceed) {
|
|
|
|
return callback(null, importInfo);
|
|
|
|
}
|
|
|
|
return callback(Errors.General('User canceled'));
|
|
|
|
});
|
|
|
|
},
|
|
|
|
(importInfo, callback) => {
|
|
|
|
fs.readFile(getConfigPath(), 'utf8', (err, configData) => {
|
|
|
|
if(err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
let config;
|
|
|
|
try {
|
|
|
|
config = hjson.rt.parse(configData);
|
|
|
|
} catch(e) {
|
|
|
|
return callback(e);
|
|
|
|
}
|
|
|
|
return callback(null, importInfo, config);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
(importInfo, config, callback) => {
|
|
|
|
const newStorageTagDirs = [];
|
|
|
|
_.each(importInfo.areas, (area, areaTag) => {
|
|
|
|
const existingArea = _.get(config, [ 'fileBase', 'areas', areaTag ]);
|
|
|
|
if(existingArea) {
|
|
|
|
return console.info(`Skipping ${area.name}. Area tag "${areaTag}" already exists.`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const storageTag = area.storageTags[0];
|
|
|
|
const existingStorageTag = _.get(config, [ 'fileBase', 'storageTags', storageTag ]);
|
|
|
|
if(existingStorageTag) {
|
|
|
|
return console.info(`Skipping ${area.name} (${areaTag}). Storage tag "${storageTag}" already exists`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const dir = importInfo.storageTags[storageTag];
|
|
|
|
newStorageTagDirs.push(dir);
|
|
|
|
|
|
|
|
config.fileBase.storageTags[storageTag] = dir;
|
|
|
|
config.fileBase.areas[areaTag] = area;
|
|
|
|
});
|
|
|
|
|
|
|
|
return callback(null, newStorageTagDirs, config);
|
|
|
|
},
|
|
|
|
(newStorageTagDirs, config, callback) => {
|
|
|
|
if(!createDirs) {
|
|
|
|
return callback(null, config);
|
|
|
|
}
|
|
|
|
|
|
|
|
//
|
|
|
|
// Create all directories
|
|
|
|
//
|
|
|
|
const prefixDir = config.fileBase.areaStoragePrefix;
|
|
|
|
async.eachSeries(newStorageTagDirs, (dir, nextDir) => {
|
|
|
|
const isAbs = paths.isAbsolute(dir);
|
|
|
|
if(!isAbs) {
|
|
|
|
dir = paths.join(prefixDir, dir);
|
|
|
|
}
|
|
|
|
mkdirs(dir, err => {
|
|
|
|
if(!err) {
|
|
|
|
console.log(`Created ${dir}`);
|
|
|
|
}
|
|
|
|
return nextDir(err);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
err => {
|
|
|
|
return callback(err, config);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
(config, callback) => {
|
|
|
|
const written = writeConfig(config, getConfigPath());
|
|
|
|
return callback(written ? null : new Error('Failed to write config!'));
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
if(err) {
|
|
|
|
return console.error(err.reason ? err.reason : err.message);
|
|
|
|
}
|
|
|
|
|
|
|
|
console.info('Import complete.');
|
|
|
|
console.info(`You may wish to validate changes made to ${getConfigPath()}`);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2018-12-21 21:39:57 +00:00
|
|
|
function setFileDescription() {
|
|
|
|
//
|
|
|
|
// ./oputil.js fb set-desc CRITERIA # will prompt
|
|
|
|
// ./oputil.js fb set-desc CRITERIA "The new description"
|
|
|
|
//
|
|
|
|
let fileCriteria;
|
|
|
|
let desc;
|
|
|
|
if(argv._.length > 3) {
|
|
|
|
fileCriteria = argv._[argv._.length - 2];
|
|
|
|
desc = argv._[argv._.length - 1];
|
|
|
|
} else {
|
|
|
|
fileCriteria = argv._[argv._.length - 1];
|
|
|
|
}
|
|
|
|
|
|
|
|
async.waterfall(
|
|
|
|
[
|
|
|
|
(callback) => {
|
|
|
|
return initConfigAndDatabases(callback);
|
|
|
|
},
|
|
|
|
(callback) => {
|
|
|
|
getFileEntries(fileCriteria, (err, entries) => {
|
|
|
|
if(err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
if(entries.length > 1) {
|
|
|
|
return callback(Errors.General('Criteria not specific enough.'));
|
|
|
|
}
|
|
|
|
|
|
|
|
return callback(null, entries[0]);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
(fileEntry, callback) => {
|
|
|
|
if(desc) {
|
|
|
|
return callback(null, fileEntry, desc);
|
|
|
|
}
|
|
|
|
|
|
|
|
getAnswers([
|
|
|
|
{
|
|
|
|
name : 'userDesc',
|
|
|
|
message : 'Description:',
|
|
|
|
type : 'editor',
|
|
|
|
}
|
|
|
|
],
|
|
|
|
answers => {
|
|
|
|
if(!answers.userDesc) {
|
|
|
|
return callback(Errors.General('User canceled'));
|
|
|
|
}
|
|
|
|
return callback(null, fileEntry, answers.userDesc);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
(fileEntry, newDesc, callback) => {
|
|
|
|
fileEntry.desc = newDesc;
|
|
|
|
fileEntry.persist(true, err => { // true=isUpdate
|
|
|
|
return callback(err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
],
|
|
|
|
err => {
|
|
|
|
if(err) {
|
|
|
|
process.exitCode = ExitCodes.ERROR;
|
|
|
|
console.error(err.message);
|
|
|
|
} else {
|
|
|
|
console.info('Description updated.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2017-02-16 03:27:16 +00:00
|
|
|
function handleFileBaseCommand() {
|
2017-05-24 03:55:22 +00:00
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
function errUsage() {
|
|
|
|
return printUsageAndSetExitCode(
|
|
|
|
getHelpFor('FileBase') + getHelpFor('FileOpsInfo'),
|
|
|
|
ExitCodes.ERROR
|
|
|
|
);
|
|
|
|
}
|
2017-05-24 03:55:22 +00:00
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
if(true === argv.help) {
|
|
|
|
return errUsage();
|
|
|
|
}
|
2017-02-16 03:27:16 +00:00
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
const action = argv._[1];
|
2017-02-16 03:27:16 +00:00
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
return ({
|
2018-12-21 21:39:57 +00:00
|
|
|
info : displayFileOrAreaInfo,
|
2018-12-04 06:51:43 +00:00
|
|
|
scan : scanFileAreas,
|
|
|
|
|
|
|
|
mv : moveFiles,
|
|
|
|
move : moveFiles,
|
2017-09-24 05:03:21 +00:00
|
|
|
|
2018-12-04 06:51:43 +00:00
|
|
|
rm : removeFiles,
|
|
|
|
remove : removeFiles,
|
|
|
|
del : removeFiles,
|
|
|
|
delete : removeFiles,
|
2017-09-24 05:03:21 +00:00
|
|
|
|
2018-12-04 06:51:43 +00:00
|
|
|
'import-areas' : importFileAreas,
|
2018-12-21 21:39:57 +00:00
|
|
|
|
|
|
|
desc : setFileDescription,
|
|
|
|
description : setFileDescription,
|
2018-06-22 05:15:04 +00:00
|
|
|
}[action] || errUsage)();
|
2017-02-16 03:27:16 +00:00
|
|
|
}
|