enigma-bbs/core/message_base_qwk_export.js

484 lines
18 KiB
JavaScript

// ENiGMA½
const { MenuModule } = require('./menu_module');
const Message = require('./message');
const { Errors } = require('./enig_error');
const {
getMessageAreaByTag,
getMessageConferenceByTag,
hasMessageConfAndAreaRead,
getAllAvailableMessageAreaTags,
} = require('./message_area');
const FileArea = require('./file_base_area');
const { QWKPacketWriter } = require('./qwk_mail_packet');
const { renderSubstr } = require('./string_util');
const Config = require('./config').get;
const FileEntry = require('./file_entry');
const DownloadQueue = require('./download_queue');
const { getISOTimestampString } = require('./database');
// deps
const async = require('async');
const _ = require('lodash');
const fse = require('fs-extra');
const temptmp = require('temptmp');
const paths = require('path');
const { v4: UUIDv4 } = require('uuid');
const moment = require('moment');
const FormIds = {
main: 0,
};
const MciViewIds = {
main: {
status: 1,
progressBar: 2,
customRangeStart: 10,
},
};
const UserProperties = {
ExportOptions: 'qwk_export_options',
ExportAreas: 'qwk_export_msg_areas',
};
exports.moduleInfo = {
name: 'QWK Export',
desc: 'Exports a QWK Packet for download',
author: 'NuSkooler',
};
exports.getModule = class MessageBaseQWKExport extends MenuModule {
constructor(options) {
super(options);
this.config = Object.assign(
{},
_.get(options, 'menuConfig.config'),
options.extraArgs
);
this.config.progBarChar = renderSubstr(this.config.progBarChar || '▒', 0, 1);
this.config.bbsID =
this.config.bbsID || _.get(Config(), 'messageNetworks.qwk.bbsID', 'ENIGMA');
this.tempName = `${UUIDv4().substr(-8).toUpperCase()}.QWK`;
this.sysTempDownloadArea = FileArea.getFileAreaByTag(
FileArea.WellKnownAreaTags.TempDownloads
);
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if (err) {
return cb(err);
}
async.waterfall(
[
callback => {
this.prepViewController(
'main',
FormIds.main,
mciData.menu,
err => {
return callback(err);
}
);
},
callback => {
this.temptmp = temptmp.createTrackedSession('qwkuserexp');
this.temptmp.mkdir(
{ prefix: 'enigqwkwriter-' },
(err, tempDir) => {
if (err) {
return callback(err);
}
this.tempPacketDir = tempDir;
const sysTempDownloadDir =
FileArea.getAreaDefaultStorageDirectory(
this.sysTempDownloadArea
);
// ensure dir exists
fse.mkdirs(sysTempDownloadDir, err => {
return callback(err, sysTempDownloadDir);
});
}
);
},
(sysTempDownloadDir, callback) => {
this._performExport(sysTempDownloadDir, err => {
return callback(err);
});
},
],
err => {
this.temptmp.cleanup();
if (err) {
// :TODO: doesn't do anything currently:
if ('NORESULTS' === err.reasonCode) {
return this.gotoMenu(
this.menuConfig.config.noResultsMenu ||
'qwkExportNoResults'
);
}
return this.prevMenu();
}
return cb(err);
}
);
});
}
finishedLoading() {
this.prevMenu();
}
_getUserQWKExportOptions() {
let qwkOptions = this.client.user.getProperty(UserProperties.ExportOptions);
try {
qwkOptions = JSON.parse(qwkOptions);
} catch (e) {
qwkOptions = {
enableQWKE: true,
enableHeadersExtension: true,
enableAtKludges: true,
archiveFormat: 'application/zip',
};
}
return qwkOptions;
}
_getUserQWKExportAreas() {
let qwkExportAreas = this.client.user.getProperty(UserProperties.ExportAreas);
try {
qwkExportAreas = JSON.parse(qwkExportAreas).map(exportArea => {
if (exportArea.newerThanTimestamp) {
exportArea.newerThanTimestamp = moment(exportArea.newerThanTimestamp);
}
return exportArea;
});
} catch (e) {
// default to all public and private without 'since'
qwkExportAreas = getAllAvailableMessageAreaTags(this.client).map(areaTag => {
return { areaTag };
});
// Include user's private area
qwkExportAreas.push({
areaTag: Message.WellKnownAreaTags.Private,
});
}
return qwkExportAreas;
}
_performExport(sysTempDownloadDir, cb) {
const statusView = this.viewControllers.main.getView(MciViewIds.main.status);
const updateStatus = status => {
if (statusView) {
statusView.setText(status);
}
};
const progBarView = this.viewControllers.main.getView(
MciViewIds.main.progressBar
);
const updateProgressBar = (curr, total) => {
if (progBarView) {
const prog = Math.floor((curr / total) * progBarView.dimens.width);
progBarView.setText(this.config.progBarChar.repeat(prog));
}
};
let cancel = false;
let lastProgUpdate = 0;
const progressHandler = (state, next) => {
// we can produce a TON of updates; only update progress at most every 3/4s
if (Date.now() - lastProgUpdate > 750) {
switch (state.step) {
case 'next_area':
updateStatus(state.status);
updateProgressBar(0, 0);
this.updateCustomViewTextsWithFilter(
'main',
MciViewIds.main.customRangeStart,
state
);
break;
case 'message':
updateStatus(state.status);
updateProgressBar(state.current, state.total);
this.updateCustomViewTextsWithFilter(
'main',
MciViewIds.main.customRangeStart,
state
);
break;
default:
break;
}
lastProgUpdate = Date.now();
}
return next(cancel ? Errors.UserInterrupt('User canceled') : null);
};
const keyPressHandler = (ch, key) => {
if ('escape' === key.name) {
cancel = true;
this.client.removeListener('key press', keyPressHandler);
}
};
let totalExported = 0;
const processMessagesWithFilter = (filter, cb) => {
Message.findMessages(filter, (err, messageIds) => {
if (err) {
return cb(err);
}
let current = 1;
async.eachSeries(
messageIds,
(messageId, nextMessageId) => {
const message = new Message();
message.load({ messageId }, err => {
if (err) {
return nextMessageId(err);
}
const progress = {
message,
step: 'message',
total: ++totalExported,
areaCurrent: current,
areaCount: messageIds.length,
status: `${_.truncate(message.subject, {
length: 25,
})} (${current} / ${messageIds.length})`,
};
progressHandler(progress, err => {
if (err) {
return nextMessageId(err);
}
packetWriter.appendMessage(message);
current += 1;
return nextMessageId(null);
});
});
},
err => {
return cb(err);
}
);
});
};
const packetWriter = new QWKPacketWriter(
Object.assign(this._getUserQWKExportOptions(), {
user: this.client.user,
bbsID: this.config.bbsID,
})
);
packetWriter.on('warning', warning => {
this.client.log.warn({ warning }, 'QWK packet writer warning');
});
async.waterfall(
[
callback => {
// don't count idle monitor while processing
this.client.stopIdleMonitor();
// let user cancel
this.client.on('key press', keyPressHandler);
packetWriter.once('ready', () => {
return callback(null);
});
packetWriter.once('error', err => {
this.client.log.error(
{ error: err.message },
'QWK packet writer error'
);
cancel = true;
});
packetWriter.init();
},
callback => {
// For each public area -> for each message
const userExportAreas = this._getUserQWKExportAreas();
const publicExportAreas = userExportAreas.filter(exportArea => {
return exportArea.areaTag !== Message.WellKnownAreaTags.Private;
});
async.eachSeries(
publicExportAreas,
(exportArea, nextExportArea) => {
const area = getMessageAreaByTag(exportArea.areaTag);
let conf;
if (area) {
conf = getMessageConferenceByTag(area.confTag);
}
if (!area || !conf) {
// :TODO: remove from user properties - this area does not exist
this.client.log.warn(
{ areaTag: exportArea.areaTag },
'Cannot QWK export area as it does not exist'
);
return nextExportArea(null);
}
if (!hasMessageConfAndAreaRead(this.client, area)) {
this.client.log.warn(
{ areaTag: area.areaTag },
'Cannot QWK export area due to ACS'
);
return nextExportArea(null);
}
const progress = {
conf,
area,
step: 'next_area',
status: `Gathering in ${conf.name} - ${area.name}...`,
};
progressHandler(progress, err => {
if (err) {
return nextExportArea(err);
}
const filter = {
resultType: 'id',
areaTag: exportArea.areaTag,
newerThanTimestamp: exportArea.newerThanTimestamp,
};
processMessagesWithFilter(filter, err => {
return nextExportArea(err);
});
});
},
err => {
return callback(err, userExportAreas);
}
);
},
(userExportAreas, callback) => {
// Private messages to current user if the user has
// elected to export private messages
const privateExportArea = userExportAreas.find(
exportArea =>
exportArea.areaTag === Message.WellKnownAreaTags.Private
);
if (!privateExportArea) {
return callback(null);
}
const filter = {
resultType: 'id',
privateTagUserId: this.client.user.userId,
newerThanTimestamp: privateExportArea.newerThanTimestamp,
};
return processMessagesWithFilter(filter, callback);
},
callback => {
let packetInfo;
packetWriter.once('packet', info => {
packetInfo = info;
});
packetWriter.once('finished', () => {
return callback(null, packetInfo);
});
packetWriter.finish(this.tempPacketDir);
},
(packetInfo, callback) => {
if (0 === totalExported) {
return callback(Errors.NothingToDo('No messages exported'));
}
const sysDownloadPath = paths.join(sysTempDownloadDir, this.tempName);
fse.move(packetInfo.path, sysDownloadPath, err => {
return callback(err, sysDownloadPath, packetInfo);
});
},
(sysDownloadPath, packetInfo, callback) => {
const newEntry = new FileEntry({
areaTag: this.sysTempDownloadArea.areaTag,
fileName: paths.basename(sysDownloadPath),
storageTag: this.sysTempDownloadArea.storageTags[0],
meta: {
upload_by_username: this.client.user.username,
upload_by_user_id: this.client.user.userId,
byte_size: packetInfo.stats.size,
session_temp_dl: 1, // download is valid until session is over
// :TODO: something like this: allow to override the displayed/downloaded as filename
// separate from the actual on disk filename. E.g. we could always download as "ENIGMA.QWK"
//visible_filename : paths.basename(packetInfo.path),
},
});
newEntry.desc = 'QWK Export';
newEntry.persist(err => {
if (!err) {
// queue it!
DownloadQueue.get(this.client).addTemporaryDownload(newEntry);
}
return callback(err);
});
},
callback => {
// update user's export area dates; they can always change/reset them again
const updatedUserExportAreas = this._getUserQWKExportAreas().map(
exportArea => {
return Object.assign(exportArea, {
newerThanTimestamp: getISOTimestampString(),
});
}
);
return this.client.user.persistProperty(
UserProperties.ExportAreas,
JSON.stringify(updatedUserExportAreas),
callback
);
},
],
err => {
this.client.startIdleMonitor(); // re-enable
this.client.removeListener('key press', keyPressHandler);
if (!err) {
updateStatus('A QWK packet has been placed in your download queue');
} else if (err.code === Errors.NothingToDo().code) {
updateStatus('No messages to export with current criteria');
err = null;
}
return cb(err);
}
);
}
};