diff --git a/LICENSE.TXT b/LICENSE.TXT index 8db0cf42..74697ba9 100644 --- a/LICENSE.TXT +++ b/LICENSE.TXT @@ -1,4 +1,4 @@ -Copyright (c) 2015-2018, Bryan D. Ashby +Copyright (c) 2015-2019, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 230878ed..1d178892 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,17 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! * Telnet, **SSH**, and both secure and non-secure [WebSocket](https://en.wikipedia.org/wiki/WebSocket) access built in! Additional servers are easy to implement * [CP437](http://www.ascii-codes.com/) and UTF-8 output * [SyncTERM](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior. - * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support + * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support. * Renegade style [pipe color codes](/docs/configuration/colour-codes.md). * [SQLite](http://sqlite.org/) storage of users, message areas, etc. * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption. * [Door support](docs/modding/door-servers.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), [Exodus](https://oddnetwork.org/exodus/) and [CombatNet](http://combatnet.us/) support! * [Bunyan](https://github.com/trentm/node-bunyan) logging! - * [Message networks](docs/messageareas/message-networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export. Messages Bases can also be set to read-only viewable using a built in Gopher server! + * [Message networks](docs/messageareas/message-networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export. Messages Bases can also be exposed via [Gopher](docs/servers/gopher.md), or [NNTP](docs/servers/nntp.md)! * [Gazelle](https://github.com/WhatCD/Gazelle) inspired File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/servers/web-server.md). Legacy X/Y/Z modem also supported! * Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more! * ANSI support in the Full Screen Editor (FSE), file descriptions, etc. + * A built in achievement system. BBSing gamified! ## Documentation [Browse the docs online](https://nuskooler.github.io/enigma-bbs/). Be sure to checkout the [/docs/](/docs/) folder as well for the latest and greatest documentation. @@ -84,7 +85,7 @@ Please see [Installation Methods](https://nuskooler.github.io/enigma-bbs/install ## License Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license: -Copyright (c) 2015-2018, Bryan D. Ashby +Copyright (c) 2015-2019, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/WHATSNEW.md b/WHATSNEW.md index 01fc8edc..39dfca49 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -26,6 +26,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * `oputil.js user rm` and `oputil.js user info` are in! See [oputil CLI](/docs/admin/oputil.md). * Performing a file scan/import using `oputil.js fb scan` now recognizes various `FILES.BBS` formats. * Usernames found in the `config.users.badUserNames` are now not only disallowed from applying, but disconnected at any login attempt. +* Total minutes online is now tracked for users. Of course, it only starts after you get the update :) ## 0.0.8-alpha diff --git a/art/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS index ad029e33..bd8a483f 100644 Binary files a/art/themes/luciano_blocktronics/MMENU.ANS and b/art/themes/luciano_blocktronics/MMENU.ANS differ diff --git a/art/themes/luciano_blocktronics/NODEMSG.ANS b/art/themes/luciano_blocktronics/NODEMSG.ANS new file mode 100644 index 00000000..ebe742df Binary files /dev/null and b/art/themes/luciano_blocktronics/NODEMSG.ANS differ diff --git a/art/themes/luciano_blocktronics/NODEMSGFTR.ANS b/art/themes/luciano_blocktronics/NODEMSGFTR.ANS new file mode 100644 index 00000000..8cb30568 Binary files /dev/null and b/art/themes/luciano_blocktronics/NODEMSGFTR.ANS differ diff --git a/art/themes/luciano_blocktronics/NODEMSGHDR.ANS b/art/themes/luciano_blocktronics/NODEMSGHDR.ANS new file mode 100644 index 00000000..9e38285a Binary files /dev/null and b/art/themes/luciano_blocktronics/NODEMSGHDR.ANS differ diff --git a/art/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS index b90ed2d9..dc2b0ca8 100644 Binary files a/art/themes/luciano_blocktronics/STATUS.ANS and b/art/themes/luciano_blocktronics/STATUS.ANS differ diff --git a/art/themes/luciano_blocktronics/USERACHIEV.ANS b/art/themes/luciano_blocktronics/USERACHIEV.ANS new file mode 100644 index 00000000..f061f04a Binary files /dev/null and b/art/themes/luciano_blocktronics/USERACHIEV.ANS differ diff --git a/art/themes/luciano_blocktronics/achievement_global_footer.ans b/art/themes/luciano_blocktronics/achievement_global_footer.ans new file mode 100644 index 00000000..8cb30568 Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_global_footer.ans differ diff --git a/art/themes/luciano_blocktronics/achievement_global_header.ans b/art/themes/luciano_blocktronics/achievement_global_header.ans new file mode 100644 index 00000000..6104c2ca Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_global_header.ans differ diff --git a/art/themes/luciano_blocktronics/achievement_local_footer.ans b/art/themes/luciano_blocktronics/achievement_local_footer.ans new file mode 100644 index 00000000..8cb30568 Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_local_footer.ans differ diff --git a/art/themes/luciano_blocktronics/achievement_local_header.ans b/art/themes/luciano_blocktronics/achievement_local_header.ans new file mode 100644 index 00000000..6104c2ca Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_local_header.ans differ diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index b1fe8aec..bd9de965 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -111,6 +111,28 @@ } } + mainMenuUserAchievementsEarned: { + config: { + dateTimeFormat: MMM Do h:mma + achievementsInfoFormat10: "|00|07\"|11{title}|07\"" + achievementsInfoFormat11: "|00|03{text}" + } + mci: { + VM1: { + height: 11 + width: 76 + itemFormat: "|00|15{ts} |07- |03{title:<47.46} |15{points:,}|07 pts" + focusItemFormat: "|00|19|15{ts} - {title:<47.46} {points:,} pts" + } + TL10: { + width: 76 + } + TL11: { + width: 76 + } + } + } + mainMenuUserStats: { mci: { UN1: { width: 15 } @@ -980,5 +1002,31 @@ } } } + + achievements: { + defaults: { + format: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + globalFormat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + titleSGR: "|10" + pointsSGR: "|12" + textSGR: "|00|03" + globalTextSGR: "|03" + boardNameSGR: "|10" + userNameSGR: "|11" + achievedValueSGR: "|15" + } + + overrides: { + user_login_count: { + match: { + 2: { + // + // You may override title, text, and globalText here + // + } + } + } + } + } } } \ No newline at end of file diff --git a/config/achievements.hjson b/config/achievements.hjson new file mode 100644 index 00000000..4ad30565 --- /dev/null +++ b/config/achievements.hjson @@ -0,0 +1,450 @@ + /* + ./\/\." ENiGMA½ Achievement Configuration -/--/-------- - -- - + + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + + *-----------------------------------------------------------------------------* + + General Information + ------------------------------- - - + This configuration is in HJSON (http://hjson.org/) format. Strict to-spec + JSON is also perfectly valid. Use "hjson" from npm to convert to/from JSON. + + See http://hjson.org/ for more information and syntax. + + Various editors and IDEs such as Sublime Text 3 Visual Studio Code and so + on have syntax highlighting for the HJSON format which are highly recommended. + + ------------------------------- -- - - + Achievement Configuration + ------------------------------- - - + Achievements are currently fairly limited in what can trigger them. This is + being expanded upon and more will be available in the near future. For now + you should mostly be interested in: + - Perhaps adding additional *levels* of triggers & points + - Applying customizations via the achievements section in theme.hjson + + Some tips: + - For 'userStatSet' types, see user_property.js + + Don"t forget to RTFM ...er uh... see the documentation for more information and + don"t be shy to ask for help: + + BBS : Xibalba @ xibalba.l33t.codes + FTN : BBS Discussion on fsxNet or ArakNet + IRC : #enigma-bbs / FreeNode + Email : bryan@l33t.codes +*/ +{ + // Set to false to disable the achievement system + enabled : true + + art : { + localHeader: achievement_local_header + localFooter: achievement_local_footer + globalHeader: achievement_global_header + globalFooter: achievement_global_footer + } + + achievements: { + user_login_count: { + type: userStatSet + statName: login_count + match: { + 2: { + title: "Return Caller" + globalText: "{userName} has returned to {boardName}!" + text: "You've returned to {boardName}!" + points: 5 + } + 10: { + title: "Curious Caller" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 10 + } + 25: { + title: "Inquisitive" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 10 + } + 75: { + title: "Still Interested!" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 15 + } + 100: { + title: "Regular Customer" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 25 + } + 250: { + title: "Speed Dial", + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 50 + } + 500: { + title: "System Addict" + globalText: "{userName} the BBS {boardName} addict has logged in {achievedValue} times!" + text: "You're a {boardName} addict! You've logged in {achievedValue} times!" + points: 50 + } + } + } + + user_post_count: { + type: userStatSet + statName: post_count + match: { + 2: { + title: "Poster" + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 5 + } + 5: { + title: "Poster... again!", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 5 + } + 20: { + title: "Just Want to Talk", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 10 + } + 100: { + title: "Probably Just Spam", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 25 + } + 250: { + title: "Scribe" + globalText: "{userName} the scribe has posted {achievedValue} messages!" + text: "Such a scribe! You've posted {achievedValue} messages!" + points: 50 + } + 500: { + title: "Writing a Book" + globalText: "{userName} is writing a book and has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 50 + } + } + } + + user_upload_count: { + type: userStatSet + statName: ul_total_count + match: { + 1: { + title: "Uploader" + globalText: "{userName} has uploaded a file!" + text: "You've uploaded somthing!" + points: 5 + } + 10: { + title: "Moar Uploads!" + globalText: "{userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 10 + } + 50: { + title: "Contributor" + globalText: "{userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 25 + + } + 100: { + title: "Courier" + globalText: "Courier {userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 50 + } + 200: { + title: "Must Be a Drop Site" + globalText: "{userName} has uploaded a whomping {achievedValue} files!" + text: "You've uploaded a whomping {achievedValue} files!" + points: 55 + } + } + } + + user_upload_bytes: { + type: userStatSet + statName: ul_total_bytes + match: { + 524288: { + title: "Kickstart" + globalText: "{userName} has uploaded 512KB, enough for a Kickstart!" + text: "You've uploaded 512KB, enough for a Kickstart!" + points: 10 + } + 1474560: { + title: "AOL Disk Anyone?" + globalText: "{userName} has uploaded 1.44M worth of data. Hopefully it's not AOL!" + title: "You've uploaded 1.44M worth of data. Hopefully it's not AOL!" + points: 10 + } + 6291456: { + title: "A Quake of a Upload" + globalText: "{userName} has uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" + text: "You've uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" + points: 20 + } + 104857600: { + title: "Zip 100" + globalText: "{userName} has uploaded a Zip 100 disk's worth of data!" + text: "You've uploaded a Zip 100 disk's worth of data!" + points: 25 + } + 1073741824: { + title: "Gigabyte!" + globalText: "{userName} has uploaded a Gigabyte worth of data!" + text: "You've uploaded a Gigabyte worth of data!" + points: 50 + } + 3407872000: { + title: "Encarta" + globalText: "{userName} has uploaded 5xCD discs worth of data. That's the size of Encarta!" + text: "You've uploaded 5xCD discs worth of data. That's the size of Encarta!" + points: 100 + } + } + } + + user_download_count: { + type: userStatSet + statName: dl_total_count + match: { + 1: { + title: "Downloader" + globalText: "{userName} has downloaded a file!" + text: "You've downloaded somthing!" + points: 5 + } + 10: { + title: "Moar Downloads!" + globalText: "{userName} has downloaded {achievedValue} files!" + text: "You've downloaded {achievedValue} files!" + points: 10 + } + 50: { + title: "Leecher" + globalText: "{userName} has leeched {achievedValue} files!" + text: "You've leeched... er... downloaded {achievedValue} files!" + points: 15 + } + 100: { + title: "Hoarder" + globalText: "{userName} has downloaded {achievedValue} files!" + text: "Hoarding files? You've downloaded {achievedValue} files!" + points: 20 + } + 200: { + title: "Digital Archivist" + globalText: "{userName} the digital archivist has {achievedValue} files!" + text: "Building an archive? You've downloaded {achievedValue} files!" + points: 25 + } + } + } + + user_download_bytes: { + type: userStatSet + statName: dl_total_bytes + match: { + 655360: { + title: "Ought to be Enough" + globalText: "{userName} has downloaded 640K. Ought to be enough for anyone!" + text: "You've downloaded 640K. Ought to be enough for anyone!" + points: 5 + } + 1474560: { + title: "Fits on a Floppy" + globalText: "{userName} has downloaded 1.44MB worth of data!" + text: "You've downloaded 1.44MB of data!" + points: 5 + } + 104857600: { + title: "Click of Death" + globalText: "{userName} has downloaded 100MB... perhaps to a Zip Disk?" + text: "You've downloaded 100MB of data... perhaps to a Zip Disk?" + points: 10 + } + 681574400: { + title: "CD Rip" + globalText: "{userName} has downloaded a CD-ROM's worth of data!" + text: "You've downloaded a CD-ROM's worth of data!" + points: 15 + } + 1073741824: { + title: "Like One Hundred Floppys, Man" + globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!" + text: "You've downloaded {achievedValue!sizeWithAbbr} of data!" + points: 25 + } + 5368709120: { + title: "That's a Lot of Bits!" + globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!" + text: "You've downloaded {achievedValue!sizeWithAbbr} of data!" + } + } + } + + user_door_runs: { + type: userStatSet + statName: door_run_total_count + match: { + 1: { + title: "Nostalgia Toe Dip", + globalText: "{userName} ran a door!" + text: "You ran a door!" + points: 5 + }, + 10: { + title: "This is Kinda Fun" + globalText: "{userName} ran {achievedValue} doors!" + text: "You've run {achievedValue} doors!" + points: 10 + } + 50: { + title: "Gamer" + globalText: "{userName} ran {achievedValue} doors!" + text: "You've run {achievedValue} doors!" + points: 20 + } + 100: { + title: "Trying Them All" + globalText: "{userName} must really like textmode and has run {achievedValue} doors!" + text: "You've run {achievedValue} doors! You must really like textmode!" + points: 50 + } + 200: { + title: "Dropfile Enthusiast" + globalText: "{userName} the dropfile enthusiast ran {achievedValue} doors!" + text: "You're a dropfile enthusiast! You've run {achievedValue} doors!" + points: 55 + } + } + } + + user_individual_door_run_minutes: { + type: userStatInc + statName: door_run_total_minutes + match: { + 1: { + title: "Nevermind!" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}. Guess it's not their thing!" + text: "You ran a door for only {achievedValue!durationMinutes}. Not your thing?" + points: 5 + } + 10: { + title: "It's OK I Guess" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}!" + text: "You ran a door for {achievedValue!durationMinutes}!" + points: 10 + } + 30: { + title: "Good Game" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}!" + text: "You ran a door for {achievedValue!durationMinutes}!" + points: 20 + } + 60: { + title: "What? Limited Turns?!" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 25 + } + 120: { + title: "It's the Only One I Know!" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 50 + } + 240: { + title: "Possible Addict" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 55 + } + } + } + + user_door_run_total_minutes: { + type: userStatIncNewVal + statName: door_run_total_minutes + match: { + 10: { + title: "Enough for the Instructions" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 10 + } + 30: { + title: "Probably Just L.O.R.D." + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 20 + } + 60: { + title: "Retro or Bust" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 25 + } + 240: { + title: "Textmode Dragon Slayer" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 50 + } + } + } + + user_total_system_online_minutes: { + type: userStatSet + statName: minutes_online_total_count + match: { + 30: { + title: "Just Poking Around" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 5 + } + 60: { + title: "Mildly Interesting" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 15 + } + 120: { + title: "Nothing Better to Do" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 25 + } + 1440: { + title: "Idle Bot" + globalText: "{userName} is probably a bot. They've spent {achievedValue!durationMinutes} on {boardName}!" + text: "You're a bot, aren't you? You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 55 + } + } + } + } +} diff --git a/core/abracadabra.js b/core/abracadabra.js index e448315b..34374049 100644 --- a/core/abracadabra.js +++ b/core/abracadabra.js @@ -6,8 +6,11 @@ const DropFile = require('./dropfile.js'); const Door = require('./door.js'); const theme = require('./theme.js'); const ansi = require('./ansi_term.js'); -const Events = require('./events.js'); const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); @@ -149,8 +152,6 @@ exports.getModule = class AbracadabraModule extends MenuModule { } runDoor() { - Events.emit(Events.getSystemEvents().UserRunDoor, { user : this.client.user } ); - this.client.term.write(ansi.resetScreen()); const exeInfo = { @@ -164,7 +165,11 @@ exports.getModule = class AbracadabraModule extends MenuModule { node : this.client.node, }; + const doorTracking = trackDoorRunBegin(this.client, this.config.name); + this.doorInstance.run(exeInfo, () => { + trackDoorRunEnd(doorTracking); + // // Try to clean up various settings such as scroll regions that may // have been set within the door diff --git a/core/achievement.js b/core/achievement.js new file mode 100644 index 00000000..a5de541f --- /dev/null +++ b/core/achievement.js @@ -0,0 +1,724 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Events = require('./events.js'); +const Config = require('./config.js').get; +const { + getConfigPath, + getFullConfig, +} = require('./config_util.js'); +const UserDb = require('./database.js').dbs.user; +const { + getISOTimestampString +} = require('./database.js'); +const UserInterruptQueue = require('./user_interrupt_queue.js'); +const { + getConnectionByUserId +} = require('./client_connections.js'); +const UserProps = require('./user_property.js'); +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); +const { getThemeArt } = require('./theme.js'); +const { + pipeToAnsi, + stripMciColorCodes +} = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); +const StatLog = require('./stat_log.js'); +const Log = require('./logger.js').log; +const ConfigCache = require('./config_cache.js'); + +// deps +const _ = require('lodash'); +const async = require('async'); +const moment = require('moment'); +const paths = require('path'); + +exports.getAchievementsEarnedByUser = getAchievementsEarnedByUser; + +class Achievement { + constructor(data) { + this.data = data; + + // achievements are retroactive by default + this.data.retroactive = _.get(this.data, 'retroactive', true); + } + + static factory(data) { + if(!data) { + return; + } + let achievement; + switch(data.type) { + case Achievement.Types.UserStatSet : + case Achievement.Types.UserStatInc : + case Achievement.Types.UserStatIncNewVal : + achievement = new UserStatAchievement(data); + break; + + default : return; + } + + if(achievement.isValid()) { + return achievement; + } + } + + static get Types() { + return { + UserStatSet : 'userStatSet', + UserStatInc : 'userStatInc', + UserStatIncNewVal : 'userStatIncNewVal', + }; + } + + isValid() { + switch(this.data.type) { + case Achievement.Types.UserStatSet : + case Achievement.Types.UserStatInc : + case Achievement.Types.UserStatIncNewVal : + if(!_.isString(this.data.statName)) { + return false; + } + if(!_.isObject(this.data.match)) { + return false; + } + break; + + default : return false; + } + return true; + } + + getMatchDetails(/*matchAgainst*/) { + } + + isValidMatchDetails(details) { + if(!details || !_.isString(details.title) || !_.isString(details.text) || !_.isNumber(details.points)) { + return false; + } + return (_.isString(details.globalText) || !details.globalText); + } +} + +class UserStatAchievement extends Achievement { + constructor(data) { + super(data); + + // sort match keys for quick match lookup + this.matchKeys = Object.keys(this.data.match || {}).map(k => parseInt(k)).sort( (a, b) => b - a); + } + + isValid() { + if(!super.isValid()) { + return false; + } + return !Object.keys(this.data.match).some(k => !parseInt(k)); + } + + getMatchDetails(matchValue) { + let ret = []; + let matchField = this.matchKeys.find(v => matchValue >= v); + if(matchField) { + const match = this.data.match[matchField]; + matchField = parseInt(matchField); + if(this.isValidMatchDetails(match) && !isNaN(matchField)) { + ret = [ match, matchField, matchValue ]; + } + } + return ret; + } +} + +class Achievements { + constructor(events) { + this.events = events; + } + + getAchievementByTag(tag) { + return this.achievementConfig.achievements[tag]; + } + + isEnabled() { + return !_.isUndefined(this.achievementConfig); + } + + init(cb) { + let achievementConfigPath = _.get(Config(), 'general.achievementFile'); + if(!achievementConfigPath) { + Log.info('Achievements are not configured'); + return cb(null); + } + achievementConfigPath = getConfigPath(achievementConfigPath); // qualify + + const configLoaded = (achievementConfig) => { + if(true !== achievementConfig.enabled) { + Log.info('Achievements are not enabled'); + this.stopMonitoringUserStatEvents(); + delete this.achievementConfig; + } else { + Log.info('Achievements are enabled'); + this.achievementConfig = achievementConfig; + this.monitorUserStatEvents(); + } + }; + + const changed = ( { fileName, fileRoot } ) => { + const reCachedPath = paths.join(fileRoot, fileName); + if(reCachedPath === achievementConfigPath) { + getFullConfig(achievementConfigPath, (err, achievementConfig) => { + if(err) { + return Log.error( { error : err.message }, 'Failed to reload achievement config from cache'); + } + configLoaded(achievementConfig); + }); + } + }; + + ConfigCache.getConfigWithOptions( + { + filePath : achievementConfigPath, + forceReCache : true, + callback : changed, + }, + (err, achievementConfig) => { + if(err) { + return cb(err); + } + + configLoaded(achievementConfig); + return cb(null); + } + ); + } + + loadAchievementHitCount(user, achievementTag, field, cb) { + UserDb.get( + `SELECT COUNT() AS count + FROM user_achievement + WHERE user_id = ? AND achievement_tag = ? AND match = ?;`, + [ user.userId, achievementTag, field], + (err, row) => { + return cb(err, row ? row.count : 0); + } + ); + } + + record(info, localInterruptItem, cb) { + StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1); + StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points); + + const recordData = [ + info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField, + stripMciColorCodes(localInterruptItem.title), stripMciColorCodes(localInterruptItem.achievText), info.details.points, + ]; + + UserDb.run( + `INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match, title, text, points) + VALUES (?, ?, ?, ?, ?, ?, ?);`, + recordData, + err => { + if(err) { + return cb(err); + } + + this.events.emit( + Events.getSystemEvents().UserAchievementEarned, + { + user : info.client.user, + achievementTag : info.achievementTag, + points : info.details.points, + } + ); + + return cb(null); + } + ); + } + + display(info, interruptItems, cb) { + if(interruptItems.local) { + UserInterruptQueue.queue(interruptItems.local, { clients : info.client } ); + } + + if(interruptItems.global) { + UserInterruptQueue.queue(interruptItems.global, { omit : info.client } ); + } + + return cb(null); + } + + recordAndDisplayAchievement(info, cb) { + async.waterfall( + [ + (callback) => { + return this.createAchievementInterruptItems(info, callback); + }, + (interruptItems, callback) => { + this.record(info, interruptItems.local, err => { + return callback(err, interruptItems); + }); + }, + (interruptItems, callback) => { + return this.display(info, interruptItems, callback); + } + ], + err => { + return cb(err); + } + ); + } + + monitorUserStatEvents() { + if(this.userStatEventListeners) { + return; // already listening + } + + const listenEvents = [ + Events.getSystemEvents().UserStatSet, + Events.getSystemEvents().UserStatIncrement + ]; + + this.userStatEventListeners = this.events.addMultipleEventListener(listenEvents, userStatEvent => { + if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) { + return; + } + + if(!_.isNumber(userStatEvent.statValue) && !_.isNumber(userStatEvent.statIncrementBy)) { + return; + } + + // :TODO: Make this code generic - find + return factory created object + const achievementTags = Object.keys(_.pickBy( + _.get(this.achievementConfig, 'achievements', {}), + achievement => { + if(false === achievement.enabled) { + return false; + } + const acceptedTypes = [ + Achievement.Types.UserStatSet, + Achievement.Types.UserStatInc, + Achievement.Types.UserStatIncNewVal, + ]; + return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName; + } + )); + + if(0 === achievementTags.length) { + return; + } + + async.eachSeries(achievementTags, (achievementTag, nextAchievementTag) => { + const achievement = Achievement.factory(this.getAchievementByTag(achievementTag)); + if(!achievement) { + return nextAchievementTag(null); + } + + const statValue = parseInt( + [ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ? + userStatEvent.statValue : + userStatEvent.statIncrementBy + ); + if(isNaN(statValue)) { + return nextAchievementTag(null); + } + + const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue); + if(!details) { + return nextAchievementTag(null); + } + + async.series( + [ + (callback) => { + this.loadAchievementHitCount(userStatEvent.user, achievementTag, matchField, (err, count) => { + if(err) { + return callback(err); + } + return callback(count > 0 ? Errors.General('Achievement already acquired', ErrorReasons.TooMany) : null); + }); + }, + (callback) => { + const client = getConnectionByUserId(userStatEvent.user.userId); + if(!client) { + return callback(Errors.UnexpectedState('Failed to get client for user ID')); + } + + const info = { + achievementTag, + achievement, + details, + client, + matchField, // match - may be in odd format + matchValue, // actual value + achievedValue : matchField, // achievement value met + user : userStatEvent.user, + timestamp : moment(), + }; + + const achievementsInfo = [ info ]; + if(true === achievement.data.retroactive) { + // For userStat, any lesser match keys(values) are also met. Example: + // matchKeys: [ 500, 200, 100, 20, 10, 2 ] + // ^---- we met here + // ^------------^ retroactive range + // + const index = achievement.matchKeys.findIndex(v => v < matchField); + if(index > -1 && Array.isArray(achievement.matchKeys)) { + achievement.matchKeys.slice(index).forEach(k => { + const [ det, fld, val ] = achievement.getMatchDetails(k); + if(det) { + achievementsInfo.push(Object.assign( + {}, + info, + { + details : det, + matchField : fld, + achievedValue : fld, + matchValue : val, + } + )); + } + }); + } + } + + // reverse achievementsInfo so we display smallest > largest + achievementsInfo.reverse(); + + async.eachSeries(achievementsInfo, (achInfo, nextAchInfo) => { + return this.recordAndDisplayAchievement(achInfo, err => { + return nextAchInfo(err); + }); + }, + err => { + return callback(err); + }); + } + ], + err => { + if(err && ErrorReasons.TooMany !== err.reasonCode) { + Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event'); + } + return nextAchievementTag(null); // always try the next, regardless + } + ); + }); + + /* + const achievementTag = _.findKey( + _.get(this.achievementConfig, 'achievements', {}), + achievement => { + if(false === achievement.enabled) { + return false; + } + const acceptedTypes = [ + Achievement.Types.UserStatSet, + Achievement.Types.UserStatInc, + Achievement.Types.UserStatIncNewVal, + ]; + return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName; + } + ); + + if(!achievementTag) { + return; + } + + const achievement = Achievement.factory(this.getAchievementByTag(achievementTag)); + if(!achievement) { + return; + } + + const statValue = parseInt( + [ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ? + userStatEvent.statValue : + userStatEvent.statIncrementBy + ); + if(isNaN(statValue)) { + return; + } + + const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue); + if(!details) { + return; + } + + async.series( + [ + (callback) => { + this.loadAchievementHitCount(userStatEvent.user, achievementTag, matchField, (err, count) => { + if(err) { + return callback(err); + } + return callback(count > 0 ? Errors.General('Achievement already acquired', ErrorReasons.TooMany) : null); + }); + }, + (callback) => { + const client = getConnectionByUserId(userStatEvent.user.userId); + if(!client) { + return callback(Errors.UnexpectedState('Failed to get client for user ID')); + } + + const info = { + achievementTag, + achievement, + details, + client, + matchField, // match - may be in odd format + matchValue, // actual value + achievedValue : matchField, // achievement value met + user : userStatEvent.user, + timestamp : moment(), + }; + + const achievementsInfo = [ info ]; + if(true === achievement.data.retroactive) { + // For userStat, any lesser match keys(values) are also met. Example: + // matchKeys: [ 500, 200, 100, 20, 10, 2 ] + // ^---- we met here + // ^------------^ retroactive range + // + const index = achievement.matchKeys.findIndex(v => v < matchField); + if(index > -1 && Array.isArray(achievement.matchKeys)) { + achievement.matchKeys.slice(index).forEach(k => { + const [ det, fld, val ] = achievement.getMatchDetails(k); + if(det) { + achievementsInfo.push(Object.assign( + {}, + info, + { + details : det, + matchField : fld, + achievedValue : fld, + matchValue : val, + } + )); + } + }); + } + } + + // reverse achievementsInfo so we display smallest > largest + achievementsInfo.reverse(); + + async.eachSeries(achievementsInfo, (achInfo, nextAchInfo) => { + return this.recordAndDisplayAchievement(achInfo, err => { + return nextAchInfo(err); + }); + }, + err => { + return callback(err); + }); + } + ], + err => { + if(err && ErrorReasons.TooMany !== err.reasonCode) { + Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event'); + } + } + );*/ + }); + } + + stopMonitoringUserStatEvents() { + if(this.userStatEventListeners) { + this.events.removeMultipleEventListener(this.userStatEventListeners); + delete this.userStatEventListeners; + } + } + + getFormatObject(info) { + return { + userName : info.user.username, + userRealName : info.user.properties[UserProps.RealName], + userLocation : info.user.properties[UserProps.Location], + userAffils : info.user.properties[UserProps.Affiliations], + nodeId : info.client.node, + title : info.details.title, + //text : info.global ? info.details.globalText : info.details.text, + points : info.details.points, + achievedValue : info.achievedValue, + matchField : info.matchField, + matchValue : info.matchValue, + timestamp : moment(info.timestamp).format(info.dateTimeFormat), + boardName : Config().general.boardName, + }; + } + + getFormattedTextFor(info, textType, defaultSgr = '|07') { + const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); + const textTypeSgr = themeDefaults[`${textType}SGR`] || defaultSgr; + + const formatObj = this.getFormatObject(info); + + const wrap = (input) => { + const re = new RegExp(`{(${Object.keys(formatObj).join('|')})([^}]*)}`, 'g'); + return input.replace(re, (m, formatVar, formatOpts) => { + const varSgr = themeDefaults[`${formatVar}SGR`] || textTypeSgr; + let r = `${varSgr}{${formatVar}`; + if(formatOpts) { + r += formatOpts; + } + return `${r}}${textTypeSgr}`; + }); + }; + + return stringFormat(`${textTypeSgr}${wrap(info.details[textType])}`, formatObj); + } + + createAchievementInterruptItems(info, cb) { + info.dateTimeFormat = + info.details.dateTimeFormat || + info.achievement.dateTimeFormat || + info.client.currentTheme.helpers.getDateTimeFormat(); + + const title = this.getFormattedTextFor(info, 'title'); + const text = this.getFormattedTextFor(info, 'text'); + + let globalText; + if(info.details.globalText) { + globalText = this.getFormattedTextFor(info, 'globalText'); + } + + const getArt = (name, callback) => { + const spec = + _.get(info.details, `art.${name}`) || + _.get(info.achievement, `art.${name}`) || + _.get(this.achievementConfig, `art.${name}`); + if(!spec) { + return callback(null); + } + const getArtOpts = { + name : spec, + client : this.client, + random : false, + }; + getThemeArt(getArtOpts, (err, artInfo) => { + // ignore errors + return callback(artInfo ? artInfo.data : null); + }); + }; + + const interruptItems = {}; + let itemTypes = [ 'local' ]; + if(globalText) { + itemTypes.push('global'); + } + + async.each(itemTypes, (itemType, nextItemType) => { + async.waterfall( + [ + (callback) => { + getArt(`${itemType}Header`, headerArt => { + return callback(null, headerArt); + }); + }, + (headerArt, callback) => { + getArt(`${itemType}Footer`, footerArt => { + return callback(null, headerArt, footerArt); + }); + }, + (headerArt, footerArt, callback) => { + const itemText = 'global' === itemType ? globalText : text; + interruptItems[itemType] = { + title, + achievText : itemText, + text : `${title}\r\n${itemText}`, + pause : true, + }; + if(headerArt || footerArt) { + const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); + const defaultContentsFormat = '{title}\r\n{message}'; + const contentsFormat = 'global' === itemType ? + themeDefaults.globalFormat || defaultContentsFormat : + themeDefaults.format || defaultContentsFormat; + + const formatObj = Object.assign(this.getFormatObject(info), { + title : this.getFormattedTextFor(info, 'title', ''), // ''=defaultSgr + message : itemText, + }); + + const contents = pipeToAnsi(stringFormat(contentsFormat, formatObj)); + + interruptItems[itemType].contents = + `${headerArt || ''}\r\n${contents}\r\n${footerArt || ''}`; + } + return callback(null); + } + ], + err => { + return nextItemType(err); + } + ); + }, + err => { + return cb(err, interruptItems); + }); + } +} + +let achievementsInstance; + +function getAchievementsEarnedByUser(userId, cb) { + if(!achievementsInstance) { + return cb(Errors.UnexpectedState('Achievements not initialized')); + } + + UserDb.all( + `SELECT achievement_tag, timestamp, match, title, text, points + FROM user_achievement + WHERE user_id = ? + ORDER BY DATETIME(timestamp);`, + [ userId ], + (err, rows) => { + if(err) { + return cb(err); + } + + const earned = rows.map(row => { + + const achievement = Achievement.factory(achievementsInstance.getAchievementByTag(row.achievement_tag)); + if(!achievement) { + return; + } + + const earnedInfo = { + achievementTag : row.achievement_tag, + type : achievement.data.type, + retroactive : achievement.data.retroactive, + title : row.title, + text : row.text, + points : row.points, + timestamp : moment(row.timestamp), + }; + + switch(earnedInfo.type) { + case [ Achievement.Types.UserStatSet ] : + case [ Achievement.Types.UserStatInc ] : + case [ Achievement.Types.UserStatIncNewVal ] : + earnedInfo.statName = achievement.data.statName; + break; + } + + return earnedInfo; + }).filter(a => a); // remove any empty records (ie: no achievement.hjson entry exists anymore). + + return cb(null, earned); + } + ); +} + +exports.moduleInitialize = (initInfo, cb) => { + achievementsInstance = new Achievements(initInfo.events); + achievementsInstance.init( err => { + if(err) { + return cb(err); + } + + return cb(null); + }); +}; diff --git a/core/acs_parser.js b/core/acs_parser.js index d6983b17..d4084b95 100644 --- a/core/acs_parser.js +++ b/core/acs_parser.js @@ -1004,7 +1004,7 @@ function peg$parse(input, options) { TW : function termWidth() { return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value; }, - ID : function isUserId(value) { + ID : function isUserId() { if(!user) { return false; } @@ -1024,6 +1024,20 @@ function peg$parse(input, options) { const midnight = now.clone().startOf('day') const minutesPastMidnight = now.diff(midnight, 'minutes'); return !isNaN(value) && minutesPastMidnight >= value; + }, + AC : function achievementCount() { + if(!user) { + return false; + } + const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0; + return !isNan(value) && points >= value; + }, + AP : function achievementPoints() { + if(!user) { + return false; + } + const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0; + return !isNan(value) && points >= value; } }[acsCode](value); } catch (e) { diff --git a/core/ansi_term.js b/core/ansi_term.js index f00fd011..353c46c8 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -23,6 +23,8 @@ // General // * http://en.wikipedia.org/wiki/ANSI_escape_code // * http://www.inwap.com/pdp10/ansicode.txt +// * Excellent information with many standards covered (for hterm): +// https://chromium.googlesource.com/apps/libapps/+/master/hterm/doc/ControlSequences.md // // Other Implementations // * https://github.com/chjj/term.js/blob/master/src/term.js diff --git a/core/bbs_link.js b/core/bbs_link.js index 71fa04c1..01eb3bfe 100644 --- a/core/bbs_link.js +++ b/core/bbs_link.js @@ -4,6 +4,10 @@ const { MenuModule } = require('./menu_module.js'); const { resetScreen } = require('./ansi_term.js'); const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); @@ -98,7 +102,7 @@ exports.getModule = class BBSLinkModule extends MenuModule { // // Authenticate the token we acquired previously // - var headers = { + const headers = { 'X-User' : self.client.user.userId.toString(), 'X-System' : self.config.sysCode, 'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'), @@ -125,17 +129,19 @@ exports.getModule = class BBSLinkModule extends MenuModule { // Authentication with BBSLink successful. Now, we need to create a telnet // bridge from us to them // - var connectOpts = { + const connectOpts = { port : self.config.port, host : self.config.host, }; - var clientTerminated; + let clientTerminated; self.client.term.write(resetScreen()); self.client.term.write(' Connecting to BBSLink.net, please wait...\n'); - var bridgeConnection = net.createConnection(connectOpts, function connected() { + const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`); + + const bridgeConnection = net.createConnection(connectOpts, function connected() { self.client.log.info(connectOpts, 'BBSLink bridge connection established'); self.client.term.output.pipe(bridgeConnection); @@ -143,13 +149,15 @@ exports.getModule = class BBSLinkModule extends MenuModule { self.client.once('end', function clientEnd() { self.client.log.info('Connection ended. Terminating BBSLink connection'); clientTerminated = true; - bridgeConnection.end(); + bridgeConnection.end(); }); }); - var restorePipe = function() { + const restorePipe = function() { self.client.term.output.unpipe(bridgeConnection); self.client.term.output.resume(); + + trackDoorRunEnd(doorTracking); }; bridgeConnection.on('data', function incomingData(data) { diff --git a/core/client.js b/core/client.js index 300285a3..894119bf 100644 --- a/core/client.js +++ b/core/client.js @@ -40,6 +40,7 @@ const MenuStack = require('./menu_stack.js'); const ACS = require('./acs.js'); const Events = require('./events.js'); const UserInterruptQueue = require('./user_interrupt_queue.js'); +const UserProps = require('./user_property.js'); // deps const stream = require('stream'); @@ -442,13 +443,36 @@ Client.prototype.startIdleMonitor = function() { // // Every 1m, check for idle. + // We also update minutes spent online the system here, + // if we have a authenticated user. // this.idleCheck = setInterval( () => { const nowMs = Date.now(); - const idleLogoutSeconds = this.user.isAuthenticated() ? - Config().users.idleLogoutSeconds : - Config().users.preAuthIdleLogoutSeconds; + let idleLogoutSeconds; + if(this.user.isAuthenticated()) { + idleLogoutSeconds = Config().users.idleLogoutSeconds; + + // + // We don't really want to be firing off an event every 1m for + // every user, but want at least some updates for various things + // such as achievements. Send off every 5m. + // + const minOnline = this.user.incrementProperty(UserProps.MinutesOnlineTotalCount, 1); + if(0 === (minOnline % 5)) { + Events.emit( + Events.getSystemEvents().UserStatIncrement, + { + user : this.user, + statName : UserProps.MinutesOnlineTotalCount, + statIncrementBy : 1, + statValue : minOnline + } + ); + } + } else { + idleLogoutSeconds = Config().users.preAuthIdleLogoutSeconds; + } if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) { this.emit('idle timeout'); @@ -473,6 +497,14 @@ Client.prototype.end = function () { currentModule.leave(); } + // persist time online for authenticated users + if(this.user.isAuthenticated()) { + this.user.persistProperty( + UserProps.MinutesOnlineTotalCount, + this.user.getProperty(UserProps.MinutesOnlineTotalCount) + ); + } + this.stopIdleMonitor(); try { diff --git a/core/client_connections.js b/core/client_connections.js index 558d0a0f..21aa5c1c 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -60,12 +60,25 @@ function getActiveConnectionList(authUsersOnly) { } function addNewClient(client, clientSock) { - const id = client.session.id = clientConnections.push(client) - 1; - const remoteAddress = client.remoteAddress = clientSock.remoteAddress; + // + // Assign ID/client ID to next lowest & available # + // + let id = 0; + for(let i = 0; i < clientConnections.length; ++i) { + if(clientConnections[i].id > id) { + break; + } + id++; + } + client.session.id = id; + const remoteAddress = client.remoteAddress = clientSock.remoteAddress; // create a unique identifier one-time ID for this session client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, moment().valueOf() ]); + clientConnections.push(client); + clientConnections.sort( (c1, c2) => c1.session.id - c2.session.id); + // Create a client specific logger // Note that this will be updated @ login with additional information client.log = logger.log.child( { clientId : id, sessionId : client.session.uniqueId } ); @@ -107,7 +120,8 @@ function removeClient(client) { ); if(client.user && client.user.isValid()) { - Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user } ); + const minutesOnline = moment().diff(moment(client.user.properties[UserProps.LastLoginTs]), 'minutes'); + Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user, minutesOnline } ); } Events.emit( diff --git a/core/color_codes.js b/core/color_codes.js index 4119a8ce..ff08275e 100644 --- a/core/color_codes.js +++ b/core/color_codes.js @@ -131,7 +131,7 @@ function renegadeToAnsi(s, client) { // // Supported control code formats: // * Renegade : |## -// * PCBoard : @X## where the first number/char is FG color, and second is BG +// * PCBoard : @X## where the first number/char is BG color, and second is FG // * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix // * WWIV : ^# // * CNET Y-Style : 0x19## where ## is a specific set of codes -- this is the older format @@ -179,26 +179,6 @@ function controlCodesToAnsi(s, client) { v = m[4]; } - fg = { - 0 : [ 'reset', 'black' ], - 1 : [ 'reset', 'blue' ], - 2 : [ 'reset', 'green' ], - 3 : [ 'reset', 'cyan' ], - 4 : [ 'reset', 'red' ], - 5 : [ 'reset', 'magenta' ], - 6 : [ 'reset', 'yellow' ], - 7 : [ 'reset', 'white' ], - - 8 : [ 'blink', 'black' ], - 9 : [ 'blink', 'blue' ], - A : [ 'blink', 'green' ], - B : [ 'blink', 'cyan' ], - C : [ 'blink', 'red' ], - D : [ 'blink', 'magenta' ], - E : [ 'blink', 'yellow' ], - F : [ 'blink', 'white' ], - }[v.charAt(0)] || ['normal']; - bg = { 0 : [ 'blackBG' ], 1 : [ 'blueBG' ], @@ -217,7 +197,27 @@ function controlCodesToAnsi(s, client) { D : [ 'bold', 'magentaBG' ], E : [ 'bold', 'yellowBG' ], F : [ 'bold', 'whiteBG' ], - }[v.charAt(1)] || [ 'normal' ]; + }[v.charAt(0)] || [ 'normal' ]; + + fg = { + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'blink', 'black' ], + 9 : [ 'blink', 'blue' ], + A : [ 'blink', 'green' ], + B : [ 'blink', 'cyan' ], + C : [ 'blink', 'red' ], + D : [ 'blink', 'magenta' ], + E : [ 'blink', 'yellow' ], + F : [ 'blink', 'white' ], + }[v.charAt(1)] || ['normal']; v = ANSI.sgr(fg.concat(bg)); result += s.substr(lastIndex, m.index - lastIndex) + v; diff --git a/core/combatnet.js b/core/combatnet.js index abb9a889..8f1a5623 100644 --- a/core/combatnet.js +++ b/core/combatnet.js @@ -5,6 +5,10 @@ const { MenuModule } = require('../core/menu_module.js'); const { resetScreen } = require('../core/ansi_term.js'); const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); @@ -46,9 +50,15 @@ exports.getModule = class CombatNetModule extends MenuModule { self.client.term.write(resetScreen()); self.client.term.write('Connecting to CombatNet, please wait...\n'); + let doorTracking; + const restorePipeToNormal = function() { if(self.client.term.output) { self.client.term.output.removeListener('data', sendToRloginBuffer); + + if(doorTracking) { + trackDoorRunEnd(doorTracking); + } } }; @@ -90,6 +100,7 @@ exports.getModule = class CombatNetModule extends MenuModule { self.client.log.info('Connected to CombatNet'); self.client.term.output.on('data', sendToRloginBuffer); + doorTracking = trackDoorRunBegin(self.client); } else { return callback(Errors.General('Failed to establish establish CombatNet connection')); } diff --git a/core/config.js b/core/config.js index 9c9c4cd4..fa99d5da 100644 --- a/core/config.js +++ b/core/config.js @@ -175,6 +175,7 @@ function getDefaultConfig() { menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path + achievementFile : 'achievements.hjson', }, users : { @@ -1003,6 +1004,6 @@ function getDefaultConfig() { systemEvents : { loginHistoryMax: -1, // set to -1 for forever } - } + }, }; } diff --git a/core/config_util.js b/core/config_util.js index bda0e1bd..d64c7a24 100644 --- a/core/config_util.js +++ b/core/config_util.js @@ -10,6 +10,7 @@ const paths = require('path'); const async = require('async'); exports.init = init; +exports.getConfigPath = getConfigPath; exports.getFullConfig = getFullConfig; function getConfigPath(filePath) { diff --git a/core/connect.js b/core/connect.js index f4645133..5d45eaa4 100644 --- a/core/connect.js +++ b/core/connect.js @@ -200,7 +200,7 @@ function displayBanner(term) { // note: intentional formatting: term.pipeWrite(` |06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN -|06Copyright (c) 2014-2018 Bryan Ashby |14- |12http://l33t.codes/ +|06Copyright (c) 2014-2019 Bryan Ashby |14- |12http://l33t.codes/ |06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/ |00` ); diff --git a/core/database.js b/core/database.js index 040cc1de..91f56a04 100644 --- a/core/database.js +++ b/core/database.js @@ -189,6 +189,20 @@ const DB_INIT_TABLE = { );` ); + dbs.user.run( + `CREATE TABLE IF NOT EXISTS user_achievement ( + user_id INTEGER NOT NULL, + achievement_tag VARCHAR NOT NULL, + timestamp DATETIME NOT NULL, + match VARCHAR NOT NULL, + title VARCHAR NOT NULL, + text VARCHAR NOT NULL, + points INTEGER NOT NULL, + UNIQUE(user_id, achievement_tag, match), + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE + );` + ); + return cb(null); }, diff --git a/core/door_party.js b/core/door_party.js index f6bc7be9..184416f7 100644 --- a/core/door_party.js +++ b/core/door_party.js @@ -5,6 +5,10 @@ const { MenuModule } = require('./menu_module.js'); const { resetScreen } = require('./ansi_term.js'); const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); @@ -54,10 +58,16 @@ exports.getModule = class DoorPartyModule extends MenuModule { let pipeRestored = false; let pipedStream; + let doorTracking; + const restorePipe = function() { if(pipedStream && !pipeRestored && !clientTerminated) { self.client.term.output.unpipe(pipedStream); self.client.term.output.resume(); + + if(doorTracking) { + trackDoorRunEnd(doorTracking); + } } }; @@ -75,6 +85,8 @@ exports.getModule = class DoorPartyModule extends MenuModule { return callback(Errors.General('Failed to establish tunnel')); } + doorTracking = trackDoorRunBegin(self.client); + // // Send rlogin // DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g. @@ -100,6 +112,7 @@ exports.getModule = class DoorPartyModule extends MenuModule { sshClient.on('error', err => { self.client.log.info(`DoorParty SSH client error: ${err.message}`); + trackDoorRunEnd(doorTracking); }); sshClient.on('close', () => { @@ -122,7 +135,7 @@ exports.getModule = class DoorPartyModule extends MenuModule { self.client.log.warn( { error : err.message }, 'DoorParty error'); } - // if the client is stil here, go to previous + // if the client is still here, go to previous if(!clientTerminated) { self.prevMenu(); } diff --git a/core/door_util.js b/core/door_util.js new file mode 100644 index 00000000..6517f1be --- /dev/null +++ b/core/door_util.js @@ -0,0 +1,38 @@ +/* jslint node: true */ +'use strict'; + +const UserProps = require('./user_property.js'); +const Events = require('./events.js'); +const StatLog = require('./stat_log.js'); + +const moment = require('moment'); + +exports.trackDoorRunBegin = trackDoorRunBegin; +exports.trackDoorRunEnd = trackDoorRunEnd; + +function trackDoorRunBegin(client, doorTag) { + const startTime = moment(); + return { startTime, client, doorTag }; +} + +function trackDoorRunEnd(trackInfo) { + const { startTime, client, doorTag } = trackInfo; + + const diff = moment.duration(moment().diff(startTime)); + if(diff.asSeconds() >= 45) { + StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalCount, 1); + } + + const runTimeMinutes = Math.floor(diff.asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + + const eventInfo = { + runTimeMinutes, + user : client.user, + doorTag : doorTag || 'unknown', + }; + + Events.emit(Events.getSystemEvents().UserRunDoor, eventInfo); + } +} \ No newline at end of file diff --git a/core/events.js b/core/events.js index 73253fe3..541a5cae 100644 --- a/core/events.js +++ b/core/events.js @@ -5,6 +5,9 @@ const events = require('events'); const Log = require('./logger.js').log; const SystemEvents = require('./system_events.js'); +// deps +const _ = require('lodash'); + module.exports = new class Events extends events.EventEmitter { constructor() { super(); @@ -35,12 +38,30 @@ module.exports = new class Events extends events.EventEmitter { return super.once(event, listener); } - addListenerMultipleEvents(events, listener) { - Log.trace( { events }, 'Registring event listeners'); + // + // Listen to multiple events for a single listener. + // Called with: listener(event, eventName) + // + // The returned object must be used with removeMultipleEventListener() + // + addMultipleEventListener(events, listener) { + Log.trace( { events }, 'Registering event listeners'); + + const listeners = []; + events.forEach(eventName => { - this.on(eventName, event => { - listener(eventName, event); - }); + const listenWrapper = _.partial(listener, _, eventName); + this.on(eventName, listenWrapper); + listeners.push( { eventName, listenWrapper } ); + }); + + return listeners; + } + + removeMultipleEventListener(listeners) { + Log.trace( { events }, 'Removing listeners'); + listeners.forEach(listener => { + this.removeListener(listener.eventName, listener.listenWrapper); }); } diff --git a/core/exodus.js b/core/exodus.js index 0d439392..5ed29a4e 100644 --- a/core/exodus.js +++ b/core/exodus.js @@ -2,12 +2,18 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('./menu_module.js').MenuModule; -const resetScreen = require('./ansi_term.js').resetScreen; -const Config = require('./config.js').get; -const Errors = require('./enig_error.js').Errors; -const Log = require('./logger.js').log; -const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent; +const { MenuModule } = require('./menu_module.js'); +const { resetScreen } = require('./ansi_term.js'); +const Config = require('./config.js').get; +const { Errors } = require('./enig_error.js'); +const Log = require('./logger.js').log; +const { + getEnigmaUserAgent +} = require('./misc_util.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); @@ -151,11 +157,16 @@ exports.getModule = class ExodusModule extends MenuModule { let pipeRestored = false; let pipedStream; + let doorTracking; function restorePipe() { if(pipedStream && !pipeRestored && !clientTerminated) { self.client.term.output.unpipe(pipedStream); self.client.term.output.resume(); + + if(doorTracking) { + trackDoorRunEnd(doorTracking); + } } } @@ -186,6 +197,8 @@ exports.getModule = class ExodusModule extends MenuModule { }); sshClient.shell(window, options, (err, stream) => { + doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`); + pipedStream = stream; // :TODO: ewwwwwwwww hack self.client.term.output.pipe(stream); diff --git a/core/last_callers.js b/core/last_callers.js index f7c2552e..9d875b2e 100644 --- a/core/last_callers.js +++ b/core/last_callers.js @@ -174,7 +174,7 @@ exports.getModule = class LastCallersModule extends MenuModule { let indicatorSumsSql; if(actionIndicatorNames.length > 0) { indicatorSumsSql = actionIndicatorNames.map(i => { - return `SUM(CASE WHEN log_value='${_.snakeCase(i)}' THEN 1 ELSE 0 END) AS ${i}`; + return `SUM(CASE WHEN log_name='${_.snakeCase(i)}' THEN 1 ELSE 0 END) AS ${i}`; }); } diff --git a/core/login_server_module.js b/core/login_server_module.js index e5fccb39..8ba1d978 100644 --- a/core/login_server_module.js +++ b/core/login_server_module.js @@ -37,8 +37,8 @@ module.exports = class LoginServerModule extends ServerModule { handleNewClient(client, clientSock, modInfo) { // - // Start tracking the client. We'll assign it an ID which is - // just the index in our connections array. + // Start tracking the client. A session ID aka client ID + // will be established in addNewClient() below. // if(_.isUndefined(client.session)) { client.session = {}; diff --git a/core/menu_module.js b/core/menu_module.js index bc631feb..06c0f6d7 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -30,6 +30,10 @@ exports.MenuModule = class MenuModule extends PluginModule { this.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls); this.viewControllers = {}; this.interrupt = (_.get(this.menuConfig.config, 'interrupt', MenuModule.InterruptTypes.Queued)).toLowerCase(); + + if(MenuModule.InterruptTypes.Realtime === this.interrupt) { + this.realTimeInterrupt = 'blocked'; + } } static get InterruptTypes() { @@ -137,6 +141,7 @@ exports.MenuModule = class MenuModule extends PluginModule { }, function finishAndNext(callback) { self.finishedLoading(); + self.realTimeInterrupt = 'allowed'; return self.autoNextMenu(callback); } ], @@ -194,21 +199,28 @@ exports.MenuModule = class MenuModule extends PluginModule { } attemptInterruptNow(interruptItem, cb) { - if(MenuModule.InterruptTypes.Realtime !== this.interrupt) { + if(this.realTimeInterrupt !== 'allowed' || MenuModule.InterruptTypes.Realtime !== this.interrupt) { return cb(null, false); // don't eat up the item; queue for later } + this.realTimeInterrupt = 'blocked'; + // // Default impl: clear screen -> standard display -> reload menu // + const done = (err, removeFromQueue) => { + this.realTimeInterrupt = 'allowed'; + return cb(err, removeFromQueue); + }; + this.client.interruptQueue.displayWithItem( Object.assign({}, interruptItem, { cls : true }), err => { if(err) { - return cb(err, false); + return done(err, false); } this.reload(err => { - return cb(err, err ? false : true); + return done(err, err ? false : true); }); }); } @@ -317,7 +329,7 @@ exports.MenuModule = class MenuModule extends PluginModule { // A quick rundown: // * We may have mciData.menu, mciData.prompt, or both. // * Prompt form is favored over menu form if both are present. - // * Standard/prefdefined MCI entries must load both (e.g. %BN is expected to resolve) + // * Standard/predefined MCI entries must load both (e.g. %BN is expected to resolve) // const self = this; diff --git a/core/node_msg.js b/core/node_msg.js index bf22e24a..bb64757c 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -65,7 +65,7 @@ exports.getModule = class NodeMessageModule extends MenuModule { } } - Events.emit(Events.getSystemEvents().UserSendNodeMsg, { user : this.client.user } ); + Events.emit(Events.getSystemEvents().UserSendNodeMsg, { user : this.client.user, global : -1 === nodeId } ); return this.prevMenu(cb); }); diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 76b34fd5..a1182a79 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -11,7 +11,9 @@ const { const clientConnections = require('./client_connections.js'); const StatLog = require('./stat_log.js'); const FileBaseFilters = require('./file_base_filter.js'); -const { formatByteSize } = require('./string_util.js'); +const { + formatByteSize, +} = require('./string_util.js'); const ANSI = require('./ansi_term.js'); const UserProps = require('./user_property.js'); const SysProps = require('./system_property.js'); @@ -54,6 +56,15 @@ function userStatAsString(client, statName, defaultValue) { return (StatLog.getUserStat(client.user, statName) || defaultValue).toLocaleString(); } +function toNumberWithCommas(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +function userStatAsCountString(client, statName, defaultValue) { + const value = StatLog.getUserStatNum(client.user, statName) || defaultValue; + return toNumberWithCommas(value); +} + function sysStatAsString(statName, defaultValue) { return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString(); } @@ -90,14 +101,14 @@ const PREDEFINED_MCI_GENERATORS = { return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat()); }, US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); }, - UE : function emailAddres(client) { return userStatAsString(client, UserProps.EmailAddress, ''); }, + UE : function emailAddress(client) { return userStatAsString(client, UserProps.EmailAddress, ''); }, UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); }, UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); }, UT : function themeName(client) { return _.get(client, 'currentTheme.info.name', userStatAsString(client, UserProps.ThemeId, '')); }, UD : function themeId(client) { return userStatAsString(client, UserProps.ThemeId, ''); }, - UC : function loginCount(client) { return userStatAsString(client, UserProps.LoginCount, 0); }, + UC : function loginCount(client) { return userStatAsCountString(client, UserProps.LoginCount, 0); }, ND : function connectedNode(client) { return client.node.toString(); }, IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version ST : function serverName(client) { return client.session.serverName; }, @@ -105,12 +116,12 @@ const PREDEFINED_MCI_GENERATORS = { const activeFilter = FileBaseFilters.getActiveFilter(client); return activeFilter ? activeFilter.name : '(Unknown)'; }, - DN : function userNumDownloads(client) { return userStatAsString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2 + DN : function userNumDownloads(client) { return userStatAsCountString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2 DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes); return formatByteSize(byteSize, true); // true=withAbbr }, - UP : function userNumUploads(client) { return userStatAsString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2 + UP : function userNumUploads(client) { return userStatAsCountString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2 UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes); return formatByteSize(byteSize, true); // true=withAbbr @@ -122,10 +133,10 @@ const PREDEFINED_MCI_GENERATORS = { return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes); }, - MS : function accountCreatedclient(client) { + MS : function accountCreated(client) { return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat()); }, - PS : function userPostCount(client) { return userStatAsString(client, UserProps.MessagePostCount, 0); }, + PS : function userPostCount(client) { return userStatAsCountString(client, UserProps.MessagePostCount, 0); }, PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); }, MD : function currentMenuDescription(client) { @@ -152,6 +163,19 @@ const PREDEFINED_MCI_GENERATORS = { SH : function termHeight(client) { return client.term.termHeight.toString(); }, SW : function termWidth(client) { return client.term.termWidth.toString(); }, + AC : function achievementCount(client) { return userStatAsCountString(client, UserProps.AchievementTotalCount, 0); }, + AP : function achievementPoints(client) { return userStatAsCountString(client, UserProps.AchievementTotalPoints, 0); }, + + DR : function doorRuns(client) { return userStatAsCountString(client, UserProps.DoorRunTotalCount, 0); }, + DM : function doorFriendlyRunTime(client) { + const minutes = client.user.properties[UserProps.DoorRunTotalMinutes] || 0; + return moment.duration(minutes, 'minutes').humanize(); + }, + TO : function friendlyTotalTimeOnSystem(client) { + const minutes = client.user.properties[UserProps.MinutesOnlineTotalCount] || 0; + return moment.duration(minutes, 'minutes').humanize(); + }, + // // Date/Time // @@ -166,7 +190,7 @@ const PREDEFINED_MCI_GENERATORS = { OS : function operatingSystem() { return { linux : 'Linux', - darwin : 'Mac OS X', + darwin : 'OS X', win32 : 'Windows', sunos : 'SunOS', freebsd : 'FreeBSD', diff --git a/core/stat_log.js b/core/stat_log.js index 6cf6198b..af88ff57 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -120,11 +120,20 @@ class StatLog { // // User specific stats - // These are simply convience methods to the user's properties + // These are simply convenience methods to the user's properties // - setUserStat(user, statName, statValue, cb) { + setUserStatWithOptions(user, statName, statValue, options, cb) { // note: cb is optional in PersistUserProperty - return user.persistProperty(statName, statValue, cb); + user.persistProperty(statName, statValue, cb); + + if(!options.noEvent) { + const Events = require('./events.js'); // we need to late load currently + Events.emit(Events.getSystemEvents().UserStatSet, { user, statName, statValue } ); + } + } + + setUserStat(user, statName, statValue, cb) { + return this.setUserStatWithOptions(user, statName, statValue, {}, cb); } getUserStat(user, statName) { @@ -138,18 +147,34 @@ class StatLog { incrementUserStat(user, statName, incrementBy, cb) { incrementBy = incrementBy || 1; - let newValue = parseInt(user.properties[statName]); - if(newValue) { - if(!_.isNumber(newValue)) { - return cb(new Error(`Value for ${statName} is not a number!`)); + const oldValue = user.getPropertyAsNumber(statName) || 0; + const newValue = oldValue + incrementBy; + + this.setUserStatWithOptions( + user, + statName, + newValue, + { noEvent : true }, + err => { + if(!err) { + const Events = require('./events.js'); // we need to late load currently + Events.emit( + Events.getSystemEvents().UserStatIncrement, + { + user, + statName, + oldValue, + statIncrementBy : incrementBy, + statValue : newValue + } + ); + } + + if(cb) { + return cb(err); + } } - - newValue += incrementBy; - } else { - newValue = incrementBy; - } - - return this.setUserStat(user, statName, newValue, cb); + ); } // the time "now" in the ISO format we use and love :) @@ -344,29 +369,8 @@ class StatLog { } initUserEvents(cb) { - // - // We map some user events directly to user stat log entries such that they - // are persisted for a time. - // - const Events = require('./events.js'); - const systemEvents = Events.getSystemEvents(); - - const interestedEvents = [ - systemEvents.NewUser, - systemEvents.UserUpload, systemEvents.UserDownload, - systemEvents.UserPostMessage, systemEvents.UserSendMail, - systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg, - ]; - - Events.addListenerMultipleEvents(interestedEvents, (eventName, event) => { - this.appendUserLogEntry( - event.user, - 'system_event', - eventName.replace(/^codes\.l33t\.enigma\.system\./, ''), // strip package name prefix - 90 - ); - }); - + const systemEventUserLogInit = require('./sys_event_user_log.js'); + systemEventUserLogInit(this); return cb(null); } } diff --git a/core/string_format.js b/core/string_format.js index a756db72..4a5b110c 100644 --- a/core/string_format.js +++ b/core/string_format.js @@ -14,6 +14,7 @@ const { // deps const _ = require('lodash'); +const moment = require('moment'); /* String formatting HEAVILY inspired by David Chambers string-format library @@ -281,6 +282,10 @@ const transformers = { countWithAbbr : (n) => formatCount(n, true, 0), countWithoutAbbr : (n) => formatCount(n, false, 0), countAbbr : (n) => formatCountAbbr(n), + + durationHours : (h) => moment.duration(h, 'hours').humanize(), + durationMinutes : (m) => moment.duration(m, 'minutes').humanize(), + durationSeconds : (s) => moment.duration(s, 'seconds').humanize(), }; function transformValue(transformerName, value) { diff --git a/core/sys_event_user_log.js b/core/sys_event_user_log.js new file mode 100644 index 00000000..63ae0e55 --- /dev/null +++ b/core/sys_event_user_log.js @@ -0,0 +1,73 @@ +/* jslint node: true */ +'use strict'; + +const Events = require('./events.js'); +const LogNames = require('./user_log_name.js'); + +const DefaultKeepForDays = 365; + +module.exports = function systemEventUserLogInit(statLog) { + const systemEvents = Events.getSystemEvents(); + + const interestedEvents = [ + systemEvents.NewUser, + systemEvents.UserLogin, systemEvents.UserLogoff, + systemEvents.UserUpload, systemEvents.UserDownload, + systemEvents.UserPostMessage, systemEvents.UserSendMail, + systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg, + systemEvents.UserAchievementEarned, + ]; + + const append = (e, n, v) => { + statLog.appendUserLogEntry(e.user, n, v, DefaultKeepForDays); + }; + + Events.addMultipleEventListener(interestedEvents, (event, eventName) => { + const detailHandler = { + [ systemEvents.NewUser ] : (e) => { + append(e, LogNames.NewUser, 1); + }, + [ systemEvents.UserLogin ] : (e) => { + append(e, LogNames.Login, 1); + }, + [ systemEvents.UserLogoff ] : (e) => { + append(e, LogNames.Logoff, e.minutesOnline); + }, + [ systemEvents.UserUpload ] : (e) => { + if(e.files.length) { // we can get here for dupe uploads + append(e, LogNames.UlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0); + append(e, LogNames.UlFileBytes, totalBytes); + } + }, + [ systemEvents.UserDownload ] : (e) => { + if(e.files.length) { + append(e, LogNames.DlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.byteSize, 0); + append(e, LogNames.DlFileBytes, totalBytes); + } + }, + [ systemEvents.UserPostMessage ] : (e) => { + append(e, LogNames.PostMessage, e.areaTag); + }, + [ systemEvents.UserSendMail ] : (e) => { + append(e, LogNames.SendMail, 1); + }, + [ systemEvents.UserRunDoor ] : (e) => { + append(e, LogNames.RunDoor, e.doorTag); + append(e, LogNames.RunDoorMinutes, e.runTimeMinutes); + }, + [ systemEvents.UserSendNodeMsg ] : (e) => { + append(e, LogNames.SendNodeMsg, e.global ? 'global' : 'direct'); + }, + [ systemEvents.UserAchievementEarned ] : (e) => { + append(e, LogNames.AchievementEarned, e.achievementTag); + append(e, LogNames.AchievementPointsEarned, e.points); + } + }[eventName]; + + if(detailHandler) { + detailHandler(event); + } + }); +}; diff --git a/core/system_events.js b/core/system_events.js index 0f8118a2..173f753b 100644 --- a/core/system_events.js +++ b/core/system_events.js @@ -2,23 +2,26 @@ 'use strict'; module.exports = { - ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount } - ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount } - TermDetected : 'codes.l33t.enigma.system.term_detected', // { client } + ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount } + ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount } + TermDetected : 'codes.l33t.enigma.system.term_detected', // { client } - ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId } - ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson) - MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson) - PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson) + ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId } + ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson) + MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson) + PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson) // User - includes { user, ...} - NewUser : 'codes.l33t.enigma.system.user_new', - UserLogin : 'codes.l33t.enigma.system.user_login', - UserLogoff : 'codes.l33t.enigma.system.user_logoff', - UserUpload : 'codes.l33t.enigma.system.user_upload', // {..., files[ fileEntry, ...] } - UserDownload : 'codes.l33t.enigma.system.user_download', // {..., files[ fileEntry, ...] } - UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { areaTag } - UserSendMail : 'codes.l33t.enigma.system.user_send_mail', - UserRunDoor : 'codes.l33t.enigma.system.user_run_door', - UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', + NewUser : 'codes.l33t.enigma.system.user_new', // { ... } + UserLogin : 'codes.l33t.enigma.system.user_login', // { ... } + UserLogoff : 'codes.l33t.enigma.system.user_logoff', // { ... } + UserUpload : 'codes.l33t.enigma.system.user_upload', // { ..., files[ fileEntry, ...] } + UserDownload : 'codes.l33t.enigma.system.user_download', // { ..., files[ fileEntry, ...] } + UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { ..., areaTag } + UserSendMail : 'codes.l33t.enigma.system.user_send_mail', // { ... } + UserRunDoor : 'codes.l33t.enigma.system.user_run_door', // { ..., runTimeMinutes, doorTag|unknown } + UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', // { ..., global } + UserStatSet : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } + UserStatIncrement : 'codes.l33t.enigma.system.user_stat_increment', // { ..., statName, statIncrementBy, statValue } + UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // { ..., achievementTag, points } }; diff --git a/core/theme.js b/core/theme.js index 6dfee685..9978fde3 100644 --- a/core/theme.js +++ b/core/theme.js @@ -96,7 +96,7 @@ function loadTheme(themeId, cb) { } if(false === _.get(theme, 'info.enabled')) { - return cb(Errors.General('Theme is not enalbed', ErrorReasons.ErrNotEnabled)); + return cb(Errors.General('Theme is not enabled', ErrorReasons.ErrNotEnabled)); } refreshThemeHelpers(theme); @@ -131,8 +131,9 @@ function getMergedTheme(menuConfig, promptConfig, theme) { // // Add in data we won't be altering directly from the theme // - mergedTheme.info = theme.info; - mergedTheme.helpers = theme.helpers; + mergedTheme.info = theme.info; + mergedTheme.helpers = theme.helpers; + mergedTheme.achievements = _.get(theme, 'customization.achievements'); // // merge customizer to disallow immutable MCI properties diff --git a/core/top_x.js b/core/top_x.js new file mode 100644 index 00000000..2403c380 --- /dev/null +++ b/core/top_x.js @@ -0,0 +1,235 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const UserProps = require('./user_property.js'); +const UserLogNames = require('./user_log_name.js'); +const { Errors } = require('./enig_error.js'); +const UserDb = require('./database.js').dbs.user; +const SysDb = require('./database.js').dbs.system; +const User = require('./user.js'); + +// deps +const _ = require('lodash'); +const async = require('async'); + +exports.moduleInfo = { + name : 'TopX', + desc : 'Displays users top X stats', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.topx', +}; + +const FormIds = { + menu : 0, +}; + +exports.getModule = class TopXModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + const userPropValues = _.values(UserProps); + const userLogValues = _.values(UserLogNames); + + const hasMci = (c, t) => { + if(!Array.isArray(t)) { + t = [ t ]; + } + return t.some(t => _.isObject(mciData, [ 'menu', `${t}${c}` ])); + }; + + return this.validateConfigFields( + { + mciMap : (key, config) => { + const mciCodes = Object.keys(config.mciMap).map(mci => { + return parseInt(mci); + }).filter(mci => !isNaN(mci)); + if(0 === mciCodes.length) { + return false; + } + return mciCodes.every(mci => { + const o = config.mciMap[mci]; + if(!_.isObject(o)) { + return false; + } + const type = o.type; + switch(type) { + case 'userProp' : + if(!userPropValues.includes(o.value)) { + return false; + } + // VM# must exist for this mci + if(!_.isObject(mciData, [ 'menu', `VM${mci}` ])) { + return false; + } + break; + + case 'userEventLog' : + if(!userLogValues.includes(o.value)) { + return false; + } + // VM# must exist for this mci + if(!hasMci(mci, ['VM'])) { + return false; + } + break; + + default : + return false; + } + return true; + }); + } + }, + callback + ); + }, + (callback) => { + return this.prepViewController('menu', FormIds.menu, mciData.menu, callback); + }, + (callback) => { + async.forEachSeries(Object.keys(this.config.mciMap), (mciCode, nextMciCode) => { + return this.populateTopXList(mciCode, nextMciCode); + }, + err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + }); + } + + populateTopXList(mciCode, cb) { + const listView = this.viewControllers.menu.getView(mciCode); + if(!listView) { + return cb(Errors.UnexpectedState(`Failed to get view for MCI ${mciCode}`)); + } + + const type = this.config.mciMap[mciCode].type; + switch(type) { + case 'userProp' : return this.populateTopXUserProp(listView, mciCode, cb); + case 'userEventLog' : return this.populateTopXUserEventLog(listView, mciCode, cb); + + // we should not hit here; validation happens up front + default : return cb(Errors.UnexpectedState(`Unexpected type: ${type}`)); + } + } + + rowsToItems(rows, cb) { + let position = 1; + async.mapSeries(rows, (row, nextRow) => { + this.loadUserInfo(row.user_id, (err, userInfo) => { + if(err) { + return nextRow(err); + } + return nextRow(null, Object.assign(userInfo, { position : position++, value : row.value })); + }); + }, + (err, items) => { + return cb(err, items); + }); + } + + populateTopXUserEventLog(listView, mciCode, cb) { + const mciMap = this.config.mciMap[mciCode]; + const count = listView.dimens.height || 1; + const daysBack = mciMap.daysBack; + const shouldSum = _.get(mciMap, 'sum', true); + + const valueSql = shouldSum ? 'SUM(CAST(log_value AS INTEGER))' : 'COUNT()'; + const dateSql = daysBack ? `AND DATETIME(timestamp) >= DATETIME('now', '-${daysBack} days')` : ''; + + SysDb.all( + `SELECT user_id, ${valueSql} AS value + FROM user_event_log + WHERE log_name = ? ${dateSql} + GROUP BY user_id + ORDER BY value DESC + LIMIT ${count};`, + [ mciMap.value ], + (err, rows) => { + if(err) { + return cb(err); + } + + this.rowsToItems(rows, (err, items) => { + if(err) { + return cb(err); + } + listView.setItems(items); + listView.redraw(); + return cb(null); + }); + } + ); + } + + populateTopXUserProp(listView, mciCode, cb) { + const count = listView.dimens.height || 1; + UserDb.all( + `SELECT user_id, CAST(prop_value AS INTEGER) AS value + FROM user_property + WHERE prop_name = ? + ORDER BY value DESC + LIMIT ${count};`, + [ this.config.mciMap[mciCode].value ], + (err, rows) => { + if(err) { + return cb(err); + } + + this.rowsToItems(rows, (err, items) => { + if(err) { + return cb(err); + } + listView.setItems(items); + listView.redraw(); + return cb(null); + }); + } + ); + } + + loadUserInfo(userId, cb) { + const getPropOpts = { + names : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations ] + }; + + const userInfo = { userId }; + User.getUserName(userId, (err, userName) => { + if(err) { + return cb(err); + } + + userInfo.userName = userName; + + User.loadProperties(userId, getPropOpts, (err, props) => { + if(err) { + return cb(err); + } + + userInfo.location = props[UserProps.Location] || ''; + userInfo.affils = userInfo.affiliation = props[UserProps.Affiliations] || ''; + userInfo.realName = props[UserProps.RealName] || ''; + + return cb(null, userInfo); + }); + }); + } +}; diff --git a/core/upload.js b/core/upload.js index a2f2c9ea..6eaff2ab 100644 --- a/core/upload.js +++ b/core/upload.js @@ -73,6 +73,8 @@ exports.getModule = class UploadModule extends MenuModule { constructor(options) { super(options); + this.interrupt = MenuModule.InterruptTypes.Never; + if(_.has(options, 'lastMenuResult.recvFilePaths')) { this.recvFilePaths = options.lastMenuResult.recvFilePaths; } diff --git a/core/user.js b/core/user.js index ba89b387..3b261dc6 100644 --- a/core/user.js +++ b/core/user.js @@ -443,6 +443,22 @@ module.exports = class User { ); } + setProperty(propName, propValue) { + this.properties[propName] = propValue; + } + + incrementProperty(propName, incrementBy) { + incrementBy = incrementBy || 1; + let newValue = parseInt(this.getProperty(propName)); + if(newValue) { + newValue += incrementBy; + } else { + newValue = incrementBy; + } + this.setProperty(propName, newValue); + return newValue; + } + getProperty(propName) { return this.properties[propName]; } diff --git a/core/user_achievements_earned.js b/core/user_achievements_earned.js new file mode 100644 index 00000000..1004a9d0 --- /dev/null +++ b/core/user_achievements_earned.js @@ -0,0 +1,102 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { + getAchievementsEarnedByUser +} = require('./achievement.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'User Achievements Earned', + desc : 'Lists achievements earned by a user', + author : 'NuSkooler', +}; + +const MciViewIds = { + achievementList : 1, + customRangeStart : 10, // updated @ index update +}; + +exports.getModule = class UserAchievementsEarned extends MenuModule { + constructor(options) { + super(options); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.waterfall( + [ + (callback) => { + this.prepViewController('achievements', 0, mciData.menu, err => { + return callback(err); + }); + }, + (callback) => { + return this.validateMCIByViewIds('achievements', MciViewIds.achievementList, callback); + }, + (callback) => { + return getAchievementsEarnedByUser(this.client.user.userId, callback); + }, + (achievementsEarned, callback) => { + this.achievementsEarned = achievementsEarned; + + const achievementListView = this.viewControllers.achievements.getView(MciViewIds.achievementList); + + achievementListView.on('index update', idx => { + this.selectionIndexUpdate(idx); + }); + + const dateTimeFormat = _.get( + this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateFormat('short')); + + achievementListView.setItems(achievementsEarned.map(achiev => Object.assign( + achiev, + this.getUserInfo(), + { + ts : achiev.timestamp.format(dateTimeFormat), + } + ))); + achievementListView.redraw(); + this.selectionIndexUpdate(0); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + getUserInfo() { + // :TODO: allow args to pass in a different user - ie from user list -> press A for achievs, so on... + return { + userId : this.client.user.userId, + userName : this.client.user.username, + realName : this.client.user.getProperty(UserProps.RealName), + location : this.client.user.getProperty(UserProps.Location), + affils : this.client.user.getProperty(UserProps.Affiliations), + totalCount : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalCount), + totalPoints : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalPoints), + }; + } + + selectionIndexUpdate(index) { + const achiev = this.achievementsEarned[index]; + if(!achiev) { + return; + } + this.updateCustomViewTextsWithFilter('achievements', MciViewIds.customRangeStart, achiev); + } +}; diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index 2e72bbd1..f1aee626 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -76,23 +76,33 @@ module.exports = class UserInterruptQueue displayWithItem(interruptItem, cb) { if(interruptItem.cls) { - this.client.term.rawWrite(ANSI.clearScreen()); + this.client.term.rawWrite(ANSI.resetScreen()); } else { this.client.term.rawWrite('\r\n\r\n'); } + const maybePauseAndFinish = () => { + if(interruptItem.pause) { + this.client.currentMenuModule.pausePrompt( () => { + return cb(null); + }); + } else { + return cb(null); + } + }; + if(interruptItem.contents) { Art.display(this.client, interruptItem.contents, err => { if(err) { return cb(err); } //this.client.term.rawWrite('\r\n\r\n'); // :TODO: Prob optional based on contents vs text - this.client.currentMenuModule.pausePrompt( () => { - return cb(null); - }); + maybePauseAndFinish(); }); } else { - return this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), cb); + this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), true, () => { + maybePauseAndFinish(); + }); } } }; \ No newline at end of file diff --git a/core/user_log_name.js b/core/user_log_name.js new file mode 100644 index 00000000..77fa996c --- /dev/null +++ b/core/user_log_name.js @@ -0,0 +1,22 @@ +/* jslint node: true */ +'use strict'; + +// +// Common (but not all!) user log names +// +module.exports = { + NewUser : 'new_user', + Login : 'login', + Logoff : 'logoff', + UlFiles : 'ul_files', // value=count + UlFileBytes : 'ul_file_bytes', // value=total bytes + DlFiles : 'dl_files', // value=count + DlFileBytes : 'dl_file_bytes', // value=total bytes + PostMessage : 'post_msg', // value=areaTag + SendMail : 'send_mail', + RunDoor : 'run_door', // value=doorTag|unknown + RunDoorMinutes : 'run_door_minutes', // value=minutes ran + SendNodeMsg : 'send_node_msg', // value=global|direct + AchievementEarned : 'achievement_earned', // value=achievementTag + AchievementPointsEarned : 'achievement_pts_earned', // value=points earned +}; diff --git a/core/user_property.js b/core/user_property.js index 7f2bf6c5..56e47e66 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -49,5 +49,13 @@ module.exports = { MessageConfTag : 'message_conf_tag', MessageAreaTag : 'message_area_tag', MessagePostCount : 'post_count', + + DoorRunTotalCount : 'door_run_total_count', + DoorRunTotalMinutes : 'door_run_total_minutes', + + AchievementTotalCount : 'achievement_total_count', + AchievementTotalPoints : 'achievement_total_points', + + MinutesOnlineTotalCount : 'minutes_online_total_count', }; diff --git a/docs/_includes/nav.md b/docs/_includes/nav.md index 79ce2f31..e67f1163 100644 --- a/docs/_includes/nav.md +++ b/docs/_includes/nav.md @@ -82,6 +82,7 @@ - [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %}) - [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %}) - [Node to Node Messaging]({{ site.baseurl }}{% link modding/node-msg.md %}) + - [Top X]({{ site.baseurl }}{% link modding/top-x.md %}) - Administration - [oputil]({{ site.baseurl }}{% link admin/oputil.md %}) diff --git a/docs/art/mci.md b/docs/art/mci.md index e365f393..3e5e18c5 100644 --- a/docs/art/mci.md +++ b/docs/art/mci.md @@ -16,8 +16,8 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et | Code | Description | |------|--------------| | `BN` | Board Name | -| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.3-alpha" | -| `VN` | Version *number*, eg.. "0.0.3-alpha" | +| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.9-alpha" | +| `VN` | Version *number*, eg.. "0.0.9-alpha" | | `SN` | SysOp username | | `SR` | SysOp real name | | `SL` | SysOp location | @@ -30,7 +30,7 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et | `UR` | Current user's real name | | `LO` | Current user's location | | `UA` | Current user's age | -| `BD` | Current user's birthdate (using theme date format) | +| `BD` | Current user's birthday (using theme date format) | | `US` | Current user's sex | | `UE` | Current user's email address | | `UW` | Current user's web address | @@ -58,6 +58,10 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et | `CM` | Current user's active message conference description | | `SH` | Current user's term height | | `SW` | Current user's term width | +| `AC` | Current user's total achievements | +| `AP` | Current user's total achievement points | +| `DR` | Current user's number of door runs | +| `DM` | Current user's total amount of time spent in doors | | `DT` | Current date (using theme date format) | | `CT` | Current time (using theme time format) | | `OS` | System OS (Linux, Windows, etc.) | @@ -149,10 +153,25 @@ Standard style types available for `textStyle` and `focusTextStyle`: | `mixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) | | `l33t` | 3n1gm4 bull371n b04rd 50f7w4r3 | -### Entry Fromatting +### Entry Formatting Various strings can be formatted using a syntax that allows width & precision specifiers, text styling, etc. Depending on the context, various elements can be referenced by `{name}`. Additional text styles can be supplied as well. The syntax is largely modeled after Python's [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language). ### Additional Text Styles +Some of the text styles mentioned above are also available in the mini format language: + +| Style | Description | +|-------|-------------| +| `normal` | Leaves text as-is. This is the default. | +| `toUpperCase` or `styleUpper` | ENIGMA BULLETIN BOARD SOFTWARE | +| `toLowerCase` or `styleLower` | enigma bulletin board software | +| `styleTitle` | Enigma Bulletin Board Software | +| `styleFirstLower` | eNIGMA bULLETIN bOARD sOFTWARE | +| `styleSmallVowels` | eNiGMa BuLLeTiN BoaRD SoFTWaRe | +| `styleBigVowels` | EniGMa bUllEtIn bOArd sOftwArE | +| `styleSmallI` | ENiGMA BULLETiN BOARD SOFTWARE | +| `styleMixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) | +| `styleL33t` | 3n1gm4 bull371n b04rd 50f7w4r3 | + Additional text styles are available for numbers: | Style | Description | @@ -163,6 +182,9 @@ Additional text styles are available for numbers: | `countWithAbbr` | Count with abbreviation such as `100 K`, `4.3 B`, etc. | | `countWithoutAbbr` | Just the count | | `countAbbr` | Just the abbreviation such as `M` for millions. | +| `durationHours` | Converts the provided *hours* value to something friendly such as `4 hours`, or `4 days`. | +| `durationMinutes` | Converts the provided *minutes* to something friendly such as `10 minutes` or `2 hours` | +| `durationSeconds` | Converts the provided *seconds* to something friendly such as `23 seconds` or `2 minutes` | #### Examples diff --git a/docs/configuration/acs.md b/docs/configuration/acs.md index 1ed83bb5..d0a45d06 100644 --- a/docs/configuration/acs.md +++ b/docs/configuration/acs.md @@ -34,7 +34,9 @@ The following are ACS codes available as of this writing: | NRratio | User has upload/download count ratio >= _ratio_ | | KRratio | User has a upload/download byte ratio >= _ratio_ | | PCratio | User has a post/call ratio >= _ratio_ | -| MMminutes | It is currently >= _minutes_ past midnight (system time) +| MMminutes | It is currently >= _minutes_ past midnight (system time) | +| ACachievementCount | User has >= _achievementCount_ achievements | +| APachievementPoints | User has >= _achievementPoints_ achievement points | \* Many more ACS codes are planned for the near future. diff --git a/docs/installation/testing.md b/docs/installation/testing.md index 9238fc61..b23616f2 100644 --- a/docs/installation/testing.md +++ b/docs/installation/testing.md @@ -13,7 +13,7 @@ _Note that if you've used the [Docker](docker) installation method, you've alrea If everything went OK: ```bash -ENiGMA½ Copyright (c) 2014-2018 Bryan Ashby +ENiGMA½ Copyright (c) 2014-2019 Bryan Ashby _____________________ _____ ____________________ __________\_ / \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! // __|___// | \// |// | \// | | \// \ /___ /_____ diff --git a/docs/installation/windows.md b/docs/installation/windows.md index a68afe97..a9fcf060 100644 --- a/docs/installation/windows.md +++ b/docs/installation/windows.md @@ -1,14 +1,14 @@ --- layout: page -title: Windows Full Install +title: Installation Under Windows --- +## Installation Under Windows -ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit doors natively then you should use a 32bit Windows. - +ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit doors natively then you should use a 32bit Windows. ### Basic Instructions -1. Download and Install [Node.JS](https://nodejs.org/en/download/). +1. Download and Install [Node.JS](https://nodejs.org/). 1. Upgrade NPM : At this time node comes with NPM 5.6 preinstalled. To upgrade to a newer version now or in the future on windows follow this method. `*Run PowerShell as Administrator` diff --git a/docs/modding/last-callers.md b/docs/modding/last-callers.md index 830244d7..0e15b4f8 100644 --- a/docs/modding/last-callers.md +++ b/docs/modding/last-callers.md @@ -14,13 +14,15 @@ Available `config` block entries: * `sysop`: Sysop options: * `collapse`: Collapse or roll up entries that fall within the period specified. May be a string in the form of `30 minutes`, `3 weeks`, `1 hour`, etc. * `hide`: Hide all +op logins -* `actionIndicators`: Maps user actions to indicators. For example: `userDownload` to "D". Available indicators: - * `userDownload` - * `userUpload` - * `userPostMsg` - * `userSendMail` - * `userRunDoor` - * `userSendNodeMsg` +* `actionIndicators`: Maps user events/actions to indicators. For example: `userDownload` to "D". Available indicators: + * `newUser`: User is new. + * `dlFiles`: User downloaded file(s). + * `ulFiles`: User uploaded file(s). + * `postMsg`: User posted message(s) to the message base, EchoMail, etc. + * `sendMail`: User sent _private_ mail. + * `runDoor`: User ran door(s). + * `sendNodeMsg`: User sent a node message(s). + * `achievementEarned`: User earned an achievement(s). * `actionIndicatorDefault`: Default indicator when an action is not set. Defaults to "-". Remember that entries such as `actionIndicators` and `actionIndicatorDefault` may contain pipe color codes! diff --git a/docs/modding/top-x.md b/docs/modding/top-x.md new file mode 100644 index 00000000..50d69bee --- /dev/null +++ b/docs/modding/top-x.md @@ -0,0 +1,60 @@ +--- +layout: page +title: TopX +--- +## The TopX Module +The built in `top_x` module allows for displaying oLDSKOOL (?!) top user stats for the week, month, etc. Ops can configure what stat(s) are displayed and how far back in days the stats are considered. + +## Configuration +### Config Block +Available `config` block entries: +* `mciMap`: Supplies a mapping of MCI code to data source. See `mciMap` below. + +#### MCI Map (mciMap) +The `mciMap` `config` block configures MCI code mapping to data sources. Currently the following data sources (determined by `type`) are available: + +| Type | Description | +|-------------|-------------| +| `userEventLog` | Top counts or sum of values found in the User Event Log. | +| `userProp` | Top values (aka "scores") from user properties. | + +##### User Event Log (userEventLog) +When `type` is set to `userEventLog`, entries from the User Event Log can be counted (ie: individual instances of a particular log item) or summed in the case of log items that have numeric values. The default is to sum. + +Some current User Event Log `value` examples include `ul_files`, `dl_file_bytes`, or `achievement_earned`. See [user_log_name.js](/core/user_log_name.js) for additional information. + +Example `userEventLog` entry: +```hjson +mciMap: { + 1: { // e.g.: %VM1 + type: userEventLog + value: achievement_pts_earned // top achievement points earned + sum: true // this is the default + daysBack: 7 // omit daysBack for all-of-time + } +} +``` + +#### User Properties (userProp) +When `type` is set to `userProp`, data is collected from individual user's properties. For example a `value` of `minutes_online_total_count`. See [user_property.js](/core/user_property.js) for more information. + +Example `userProp` entry: +```hjson +mciMap: { + 2: { // e.g.: %VM2 + type: userProp + value: minutes_online_total_count // top users by minutes spent on the board + } +} +``` + +### Theming +Generally `mciMap` entries will point to a Vertical List View Menu (`%VM1`, `%VM2`, etc.). The following `itemFormat` object is provided: +* `value`: The value acquired from the supplied data source. +* `userName`: User's username. +* `realName`: User's real name. +* `location`: User's location. +* `affils` or `affiliation`: Users affiliations. +* `position`: Rank position (numeric). + +Remember that string format rules apply, so for example, if displaying top uploaded bytes (`ul_file_bytes`), a `itemFormat` may be `{userName} - {value!sizeWithAbbr}` yielding something like "TopDude - 4 GB". See [MCI](/docs/art/mci.md) for additional information. diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs index bd6a8d96..8a39deea 100644 --- a/misc/acs_parser.pegjs +++ b/misc/acs_parser.pegjs @@ -160,7 +160,7 @@ TW : function termWidth() { return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value; }, - ID : function isUserId(value) { + ID : function isUserId() { if(!user) { return false; } @@ -180,6 +180,20 @@ const midnight = now.clone().startOf('day') const minutesPastMidnight = now.diff(midnight, 'minutes'); return !isNaN(value) && minutesPastMidnight >= value; + }, + AC : function achievementCount() { + if(!user) { + return false; + } + const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0; + return !isNan(value) && points >= value; + }, + AP : function achievementPoints() { + if(!user) { + return false; + } + const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0; + return !isNan(value) && points >= value; } }[acsCode](value); } catch (e) { diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index 82a3893f..d303f452 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -1,16 +1,16 @@ { - /* - ./\/\.' ENiGMA½ Menu Configuration -/--/-------- - -- - + /* + ./\/\.' ENiGMA½ Menu Configuration -/--/-------- - -- - - _____________________ _____ ____________________ __________\_ / - \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! - // __|___// | \// |// | \// | | \// \ /___ /_____ - /____ _____| __________ ___|__| ____| \ / _____ \ - ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ - /__ _\ - <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ - *-----------------------------------------------------------------------------* + *-----------------------------------------------------------------------------* General Information ------------------------------- - - @@ -47,1713 +47,1738 @@ FTN : BBS Discussion on fsxNet IRC : #enigma-bbs / FreeNode Email : bryan@l33t.codes - */ - menus: { - // - // Send telnet connections to matrix where users can login, apply, etc. - // - telnetConnected: { - art: CONNECT - next: matrix - config: { nextTimeout: 1500 } - } - - // - // SSH connections are pre-authenticated via the SSH server itself. - // Jump directly to the login sequence - // - sshConnected: { - art: CONNECT - next: fullLoginSequenceLoginArt - config: { nextTimeout: 1500 } - } - - // - // Another SSH specialization: If the user logs in with a new user - // name (e.g. "new", "apply", ...) they will be directed to the - // application process. - // - sshConnectedNewUser: { - art: CONNECT - next: newUserApplicationPreSsh - config: { nextTimeout: 1500 } - } - - // Ye ol' standard matrix - matrix: { - art: matrix - form: { - 0: { - VM: { - mci: { - VM1: { - submit: true - focus: true - argName: navSelect - // - // To enable forgot password, you will need to have the web server - // enabled and mail/SMTP configured. Once that is in place, swap out - // the commented lines below as well as in the submit block - // - items: [ - { - text: login - data: login - } - { - text: apply - data: apply - } - { - text: forgot pass - data: forgot - } - { - text: log off - data: logoff - } - ] - } - } - submit: { - *: [ - { - value: { navSelect: "login" } - action: @menu:login - } - { - value: { navSelect: "apply" } - action: @menu:newUserApplicationPre - } - { - value: { navSelect: "forgot" } - action: @menu:forgotPassword - } - { - value: { navSelect: "logoff" } - action: @menu:logoff - } - ] - } - } - } - } - } - - login: { - art: USERLOG - next: fullLoginSequenceLoginArt - config: { - tooNodeMenu: loginAttemptTooNode - inactive: loginAttemptAccountInactive - disabled: loginAttemptAccountDisabled - locked: loginAttemptAccountLocked - } - form: { - 0: { - mci: { - ET1: { - maxLength: @config:users.usernameMax - argName: username - focus: true - } - ET2: { - password: true - maxLength: @config:users.passwordMax - argName: password - submit: true - } - } - submit: { - *: [ - { - value: { password: null } - action: @systemMethod:login - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - loginAttemptTooNode: { - art: TOONODE - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - loginAttemptAccountLocked: { - art: ACCOUNTLOCKED - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - loginAttemptAccountDisabled: { - art: ACCOUNTDISABLED - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - loginAttemptAccountInactive: { - art: ACCOUNTINACTIVE - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - forgotPassword: { - desc: Forgot password - prompt: forgotPasswordPrompt - submit: [ - { - value: { username: null } - action: @systemMethod:sendForgotPasswordEmail - extraArgs: { next: "forgotPasswordSubmitted" } - } - ] - } - - forgotPasswordSubmitted: { - desc: Forgot password - art: FORGOTPWSENT - config: { - cls: true - pause: true - } - next: @systemMethod:logoff - } - - // :TODO: Prompt Yes/No for logoff confirm - fullLogoffSequence: { - desc: Logging Off - prompt: logoffConfirmation - submit: [ - { - value: { promptValue: 0 } - action: @menu:fullLogoffSequencePreAd - } - { - value: { promptValue: 1 } - action: @systemMethod:prevMenu - } - ] - } - - fullLogoffSequencePreAd: { - art: PRELOGAD - desc: Logging Off - next: fullLogoffSequenceRandomBoardAd - config: { - cls: true - nextTimeout: 1500 - } - } - - fullLogoffSequenceRandomBoardAd: { - art: OTHRBBS - desc: Logging Off - next: logoff - config: { - baudRate: 57600 - pause: true - cls: true - } - } - - logoff: { - art: LOGOFF - desc: Logging Off - next: @systemMethod:logoff - } - - // A quick preamble - defaults to warning about broken terminals - newUserApplicationPre: { - art: NEWUSER1 - next: newUserApplication - desc: Applying - config: { - pause: true - cls: true - menuFlags: [ "noHistory" ] - } - } - - newUserApplication: { - module: nua - art: NUA - next: [ - { - // Initial SysOp does not send feedback to themselves - acs: ID1 - next: fullLoginSequenceLoginArt - } - { - // ...everyone else does - next: newUserFeedbackToSysOpPreamble - } - ] - form: { - 0: { - mci: { - ET1: { - focus: true - argName: username - maxLength: @config:users.usernameMax - validate: @systemMethod:validateUserNameAvail - } - ET2: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - } - MET3: { - argName: birthdate - maskPattern: "####/##/##" - validate: @systemMethod:validateBirthdate - } - ME4: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET6: { - argName: affils - maxLength: @config:users.affilsMax - } - ET7: { - argName: email - maxLength: @config:users.emailMax - validate: @systemMethod:validateEmailAvail - } - ET8: { - argName: web - maxLength: @config:users.webMax - } - ET9: { - argName: password - password: true - maxLength: @config:users.passwordMax - validate: @systemMethod:validatePasswordSpec - } - ET10: { - argName: passwordConfirm - password: true - maxLength: @config:users.passwordMax - validate: @method:validatePassConfirmMatch - } - TM12: { - argName: submission - items: [ "apply", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitApplication - extraArgs: { - inactive: userNeedsActivated - error: newUserCreateError - } - } - { - value: { "submission" : 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - // A quick preamble - defaults to warning about broken terminals (SSH version) - newUserApplicationPreSsh: { - art: NEWUSER1 - next: newUserApplicationSsh - desc: Applying - config: { - pause: true - cls: true - menuFlags: [ "noHistory" ] - } - } - - // - // SSH specialization of NUA - // Canceling this form logs off vs falling back to matrix - // - newUserApplicationSsh: { - module: nua - art: NUA - fallback: logoff - next: newUserFeedbackToSysOpPreamble - form: { - 0: { - mci: { - ET1: { - focus: true - argName: username - maxLength: @config:users.usernameMax - validate: @systemMethod:validateUserNameAvail - } - ET2: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - } - MET3: { - argName: birthdate - maskPattern: "####/##/##" - validate: @systemMethod:validateBirthdate - } - ME4: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET6: { - argName: affils - maxLength: @config:users.affilsMax - } - ET7: { - argName: email - maxLength: @config:users.emailMax - validate: @systemMethod:validateEmailAvail - } - ET8: { - argName: web - maxLength: @config:users.webMax - } - ET9: { - argName: password - password: true - maxLength: @config:users.passwordMax - validate: @systemMethod:validatePasswordSpec - } - ET10: { - argName: passwordConfirm - password: true - maxLength: @config:users.passwordMax - validate: @method:validatePassConfirmMatch - } - TM12: { - argName: submission - items: [ "apply", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitApplication - extraArgs: { - inactive: userNeedsActivated - error: newUserCreateError - } - } - { - value: { "submission" : 1 } - action: @systemMethod:logoff - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:logoff - } - ] - } - } - } - - newUserFeedbackToSysOpPreamble: { - art: LETTER - config: { pause: true } - next: newUserFeedbackToSysOp - } - - newUserFeedbackToSysOp: { - desc: Feedback to SysOp - module: msg_area_post_fse - next: [ - { - acs: AS2 - next: fullLoginSequenceLoginArt - } - { - next: newUserInactiveDone - } - ] - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - toUserId: 1 /* always to +op */ - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: @sysStat:sysop_username - // :TODO: readOnly: true - } - ET3: { - argName: subject - maxLength: 72 - submit: true - text: New user feedback - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - newUserInactiveDone: { - desc: Finished with NUA - art: DONE - config: { pause: true } - next: @menu:logoff - } - - fullLoginSequenceLoginArt: { - desc: Logging In - art: WELCOME - config: { pause: true } - next: fullLoginSequenceLastCallers - } - - fullLoginSequenceLastCallers: { - desc: Last Callers - module: last_callers - art: LASTCALL - config: { - pause: true - font: cp437 - } - next: fullLoginSequenceWhosOnline - } - fullLoginSequenceWhosOnline: { - desc: Who's Online - module: whos_online - art: WHOSON - config: { pause: true } - next: fullLoginSequenceOnelinerz - } - - fullLoginSequenceOnelinerz: { - desc: Viewing Onelinerz - module: onelinerz - next: [ - { - // calls >= 2 - acs: NC2 - next: fullLoginSequenceNewScanConfirm - } - { - // new users - skip new scan - next: fullLoginSequenceUserStats - } - ] - config: { - cls: true - art: { - view: ONELINER - add: ONEADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: oneliner - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - fullLoginSequenceNewScanConfirm: { - desc: Logging In - prompt: loginGlobalNewScan - submit: [ - { - value: { promptValue: 0 } - action: @menu:fullLoginSequenceNewScan - } - { - value: { promptValue: 1 } - action: @menu:fullLoginSequenceUserStats - } - ] - } - - fullLoginSequenceNewScan: { - desc: Performing New Scan - module: new_scan - art: NEWSCAN - next: fullLoginSequenceSysStats - config: { - messageListMenu: newScanMessageList - } - } - - fullLoginSequenceSysStats: { - desc: System Stats - art: SYSSTAT - config: { pause: true } - next: fullLoginSequenceUserStats - } - fullLoginSequenceUserStats: { - desc: User Stats - art: STATUS - config: { pause: true } - next: mainMenu - } - - newScanMessageList: { - desc: New Messages - module: msg_list - art: NEWMSGS - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - TL6: { - // theme me! - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "x", "shift + x" ] - action: @method:fullExit - } - { - keys: [ "m", "shift + m" ] - action: @method:markAllRead - } - ] - } - } - } - - newScanFileBaseList: { - module: file_area_list - desc: New Files - config: { - art: { - browse: FNEWBRWSE - details: FDETAIL - detailsGeneral: FDETGEN - detailsNfo: FDETNFO - detailsFileList: FDETLST - help: FBHELP - } - } - form: { - 0: { - mci: { - MT1: { - mode: preview - ansiView: true - } - - HM2: { - focus: true - submit: true - argName: navSelect - items: [ - "prev", "next", "details", "toggle queue", "rate", "help", "quit" - ] - focusItemIndex: 1 - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFile - } - { - value: { navSelect: 1 } - action: @method:nextFile - } - { - value: { navSelect: 2 } - action: @method:viewDetails - } - { - value: { navSelect: 3 } - action: @method:toggleQueue - } - { - value: { navSelect: 4 } - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - value: { navSelect: 5 } - action: @method:displayHelp - } - { - value: { navSelect: 6 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "w", "shift + w" ] - action: @method:showWebDownloadLink - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "t", "shift + t" ] - action: @method:toggleQueue - } - { - keys: [ "v", "shift + v" ] - action: @method:viewDetails - } - { - keys: [ "r", "shift + r" ] - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - keys: [ "?" ] - action: @method:displayHelp - } - ] - } - - 1: { - mci: { - HM1: { - focus: true - submit: true - argName: navSelect - items: [ - "general", "nfo/readme", "file listing" - ] - } - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @method:detailsQuit - } - ] - } - - 2: { - // details - general - mci: {} - } - - 3: { - // details - nfo/readme - mci: { - MT1: { - mode: preview - } - } - } - - 4: { - // details - file listing - mci: { - VM1: { - - } - } - } - } - } - - /////////////////////////////////////////////////////////////////////// - // Main Menu - /////////////////////////////////////////////////////////////////////// - mainMenu: { - art: MMENU - desc: Main Menu - prompt: menuCommand - config: { - font: cp437 - interrupt: realtime - } - submit: [ - { - value: { command: "MSG" } - action: @menu:nodeMessage - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: { command: "D" } - action: @menu:doorMenu - } - { - value: { command: "F" } - action: @menu:fileBase - } - { - value: { command: "U" } - action: @menu:mainMenuUserList - } - { - value: { command: "L" } - action: @menu:mainMenuLastCallers - } - { - value: { command: "W" } - action: @menu:mainMenuWhosOnline - } - { - value: { command: "Y" } - action: @menu:mainMenuUserStats - } - { - value: { command: "M" } - action: @menu:messageArea - } - { - value: { command: "E" } - action: @menu:mailMenu - } - { - value: { command: "C" } - action: @menu:mainMenuUserConfig - } - { - value: { command: "S" } - action: @menu:mainMenuSystemStats - } - { - value: { command: "!" } - action: @menu:mainMenuGlobalNewScan - } - { - value: { command: "K" } - action: @menu:mainMenuFeedbackToSysOp - } - { - value: { command: "O" } - action: @menu:mainMenuOnelinerz - } - { - value: { command: "R" } - action: @menu:mainMenuRumorz - } - { - value: { command: "BBS"} - action: @menu:bbsList - } - { - value: 1 - action: @menu:mainMenu - } - ] - } - - nodeMessage: { - desc: Node Messaging - module: node_msg - art: NODEMSG - config: { - cls: true - art: { - header: NODEMSGHDR - footer: NODEMSGFTR - } - } - form: { - 0: { - mci: { - SM1: { - argName: node - } - ET2: { - argName: message - submit: true - } - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - submit: { - *: [ - { - value: { message: null } - action: @method:sendMessage - } - ] - } - } - } - } - - mainMenuLastCallers: { - desc: Last Callers - module: last_callers - art: LASTCALL - config: { pause: true } - } - - mainMenuWhosOnline: { - desc: Who's Online - module: whos_online - art: WHOSON - config: { pause: true } - } - - mainMenuUserStats: { - desc: User Stats - art: STATUS - config: { pause: true } - } - - mainMenuSystemStats: { - desc: System Stats - art: SYSSTAT - config: { pause: true } - } - - mainMenuUserList: { - desc: User Listing - module: user_list - art: USERLST - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - } - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - mainMenuUserConfig: { - module: user_config - art: CONFSCR - form: { - 0: { - mci: { - ET1: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - focus: true - } - ME2: { - argName: birthdate - maskPattern: "####/##/##" - } - ME3: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET4: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: affils - maxLength: @config:users.affilsMax - } - ET6: { - argName: email - maxLength: @config:users.emailMax - validate: @method:validateEmailAvail - } - ET7: { - argName: web - maxLength: @config:users.webMax - } - ME8: { - maskPattern: "##" - argName: termHeight - validate: @systemMethod:validateNonEmpty - } - SM9: { - argName: theme - } - ET10: { - argName: password - maxLength: @config:users.passwordMax - password: true - validate: @method:validatePassword - } - ET11: { - argName: passwordConfirm - maxLength: @config:users.passwordMax - password: true - validate: @method:validatePassConfirmMatch - } - TM25: { - argName: submission - items: [ "save", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { submission: 0 } - action: @method:saveChanges - } - { - value: { submission: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - mainMenuGlobalNewScan: { - desc: Performing New Scan - module: new_scan - art: NEWSCAN - config: { - messageListMenu: newScanMessageList - } - } - - mainMenuFeedbackToSysOp: { - desc: Feedback to SysOp - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - toUserId: 1 /* always to +op */ - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: @sysStat:sysop_username - // :TODO: readOnly: true - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - mainMenuOnelinerz: { - desc: Viewing Onelinerz - module: onelinerz - config: { - cls: true - art: { - view: ONELINER - add: ONEADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: oneliner - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - mainMenuRumorz: { - desc: Rumorz - module: rumorz - config: { - cls: true - art: { - entries: RUMORS - add: RUMORADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: rumor - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - bbsList: { - desc: Viewing BBS List - module: bbs_list - config: { - cls: true - art: { - entries: BBSLIST - add: BBSADD - } - } - - form: { - 0: { - mci: { - VM1: { maxLength: 32 } - TL2: { maxLength: 32 } - TL3: { maxLength: 32 } - TL4: { maxLength: 32 } - TL5: { maxLength: 32 } - TL6: { maxLength: 32 } - TL7: { maxLength: 32 } - TL8: { maxLength: 32 } - TL9: { maxLength: 32 } - } - actionKeys: [ - { - keys: [ "a" ] - action: @method:addBBS - } - { - // :TODO: add delete key - keys: [ "d" ] - action: @method:deleteBBS - } - { - keys: [ "q", "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - ET1: { - argName: name - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET2: { - argName: sysop - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: telnet - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET4: { - argName: www - maxLength: 32 - } - ET5: { - argName: location - maxLength: 32 - } - ET6: { - argName: software - maxLength: 32 - } - ET7: { - argName: notes - maxLength: 32 - } - TM17: { - argName: submission - items: [ "save", "cancel" ] - submit: true - } - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelSubmit - } - ] - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitBBS - } - { - value: { "submission" : 1 } - action: @method:cancelSubmit - } - ] - } - } - } - } - - /////////////////////////////////////////////////////////////////////// - // Doors Menu - /////////////////////////////////////////////////////////////////////// - doorMenu: { - desc: Doors Menu - art: DOORMNU - prompt: menuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { command: "G" } - action: @menu:logoff - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - // - // The system supports many ways of launching doors including - // modules for DoorParty!, BBSLink, etc. - // - // Below are some examples. See the documentation for more info. - // - { - value: { command: "ABRACADABRA" } - action: @menu:doorAbracadabraExample - } - { - value: { command: "TWBBSLINK" } - action: @menu:doorTradeWars2002BBSLinkExample - } - { - value: { command: "DP" } - action: @menu:doorPartyExample - } - { + */ + menus: { + // + // Send telnet connections to matrix where users can login, apply, etc. + // + telnetConnected: { + art: CONNECT + next: matrix + config: { nextTimeout: 1500 } + } + + // + // SSH connections are pre-authenticated via the SSH server itself. + // Jump directly to the login sequence + // + sshConnected: { + art: CONNECT + next: fullLoginSequenceLoginArt + config: { nextTimeout: 1500 } + } + + // + // Another SSH specialization: If the user logs in with a new user + // name (e.g. "new", "apply", ...) they will be directed to the + // application process. + // + sshConnectedNewUser: { + art: CONNECT + next: newUserApplicationPreSsh + config: { nextTimeout: 1500 } + } + + // Ye ol' standard matrix + matrix: { + art: matrix + form: { + 0: { + VM: { + mci: { + VM1: { + submit: true + focus: true + argName: navSelect + // + // To enable forgot password, you will need to have the web server + // enabled and mail/SMTP configured. Once that is in place, swap out + // the commented lines below as well as in the submit block + // + items: [ + { + text: login + data: login + } + { + text: apply + data: apply + } + { + text: forgot pass + data: forgot + } + { + text: log off + data: logoff + } + ] + } + } + submit: { + *: [ + { + value: { navSelect: "login" } + action: @menu:login + } + { + value: { navSelect: "apply" } + action: @menu:newUserApplicationPre + } + { + value: { navSelect: "forgot" } + action: @menu:forgotPassword + } + { + value: { navSelect: "logoff" } + action: @menu:logoff + } + ] + } + } + } + } + } + + login: { + art: USERLOG + next: fullLoginSequenceLoginArt + config: { + tooNodeMenu: loginAttemptTooNode + inactive: loginAttemptAccountInactive + disabled: loginAttemptAccountDisabled + locked: loginAttemptAccountLocked + } + form: { + 0: { + mci: { + ET1: { + maxLength: @config:users.usernameMax + argName: username + focus: true + } + ET2: { + password: true + maxLength: @config:users.passwordMax + argName: password + submit: true + } + } + submit: { + *: [ + { + value: { password: null } + action: @systemMethod:login + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + loginAttemptTooNode: { + art: TOONODE + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountLocked: { + art: ACCOUNTLOCKED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountDisabled: { + art: ACCOUNTDISABLED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountInactive: { + art: ACCOUNTINACTIVE + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + forgotPassword: { + desc: Forgot password + prompt: forgotPasswordPrompt + submit: [ + { + value: { username: null } + action: @systemMethod:sendForgotPasswordEmail + extraArgs: { next: "forgotPasswordSubmitted" } + } + ] + } + + forgotPasswordSubmitted: { + desc: Forgot password + art: FORGOTPWSENT + config: { + cls: true + pause: true + } + next: @systemMethod:logoff + } + + // :TODO: Prompt Yes/No for logoff confirm + fullLogoffSequence: { + desc: Logging Off + prompt: logoffConfirmation + submit: [ + { + value: { promptValue: 0 } + action: @menu:fullLogoffSequencePreAd + } + { + value: { promptValue: 1 } + action: @systemMethod:prevMenu + } + ] + } + + fullLogoffSequencePreAd: { + art: PRELOGAD + desc: Logging Off + next: fullLogoffSequenceRandomBoardAd + config: { + cls: true + nextTimeout: 1500 + } + } + + fullLogoffSequenceRandomBoardAd: { + art: OTHRBBS + desc: Logging Off + next: logoff + config: { + baudRate: 57600 + pause: true + cls: true + } + } + + logoff: { + art: LOGOFF + desc: Logging Off + next: @systemMethod:logoff + } + + // A quick preamble - defaults to warning about broken terminals + newUserApplicationPre: { + art: NEWUSER1 + next: newUserApplication + desc: Applying + config: { + pause: true + cls: true + menuFlags: [ "noHistory" ] + } + } + + newUserApplication: { + module: nua + art: NUA + next: [ + { + // Initial SysOp does not send feedback to themselves + acs: ID1 + next: fullLoginSequenceLoginArt + } + { + // ...everyone else does + next: newUserFeedbackToSysOpPreamble + } + ] + form: { + 0: { + mci: { + ET1: { + focus: true + argName: username + maxLength: @config:users.usernameMax + validate: @systemMethod:validateUserNameAvail + } + ET2: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + } + MET3: { + argName: birthdate + maskPattern: "####/##/##" + validate: @systemMethod:validateBirthdate + } + ME4: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET6: { + argName: affils + maxLength: @config:users.affilsMax + } + ET7: { + argName: email + maxLength: @config:users.emailMax + validate: @systemMethod:validateEmailAvail + } + ET8: { + argName: web + maxLength: @config:users.webMax + } + ET9: { + argName: password + password: true + maxLength: @config:users.passwordMax + validate: @systemMethod:validatePasswordSpec + } + ET10: { + argName: passwordConfirm + password: true + maxLength: @config:users.passwordMax + validate: @method:validatePassConfirmMatch + } + TM12: { + argName: submission + items: [ "apply", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitApplication + extraArgs: { + inactive: userNeedsActivated + error: newUserCreateError + } + } + { + value: { "submission" : 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + // A quick preamble - defaults to warning about broken terminals (SSH version) + newUserApplicationPreSsh: { + art: NEWUSER1 + next: newUserApplicationSsh + desc: Applying + config: { + pause: true + cls: true + menuFlags: [ "noHistory" ] + } + } + + // + // SSH specialization of NUA + // Canceling this form logs off vs falling back to matrix + // + newUserApplicationSsh: { + module: nua + art: NUA + fallback: logoff + next: newUserFeedbackToSysOpPreamble + form: { + 0: { + mci: { + ET1: { + focus: true + argName: username + maxLength: @config:users.usernameMax + validate: @systemMethod:validateUserNameAvail + } + ET2: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + } + MET3: { + argName: birthdate + maskPattern: "####/##/##" + validate: @systemMethod:validateBirthdate + } + ME4: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET6: { + argName: affils + maxLength: @config:users.affilsMax + } + ET7: { + argName: email + maxLength: @config:users.emailMax + validate: @systemMethod:validateEmailAvail + } + ET8: { + argName: web + maxLength: @config:users.webMax + } + ET9: { + argName: password + password: true + maxLength: @config:users.passwordMax + validate: @systemMethod:validatePasswordSpec + } + ET10: { + argName: passwordConfirm + password: true + maxLength: @config:users.passwordMax + validate: @method:validatePassConfirmMatch + } + TM12: { + argName: submission + items: [ "apply", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitApplication + extraArgs: { + inactive: userNeedsActivated + error: newUserCreateError + } + } + { + value: { "submission" : 1 } + action: @systemMethod:logoff + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:logoff + } + ] + } + } + } + + newUserFeedbackToSysOpPreamble: { + art: LETTER + config: { pause: true } + next: newUserFeedbackToSysOp + } + + newUserFeedbackToSysOp: { + desc: Feedback to SysOp + module: msg_area_post_fse + next: [ + { + acs: AS2 + next: fullLoginSequenceLoginArt + } + { + next: newUserInactiveDone + } + ] + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + toUserId: 1 /* always to +op */ + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: @sysStat:sysop_username + // :TODO: readOnly: true + } + ET3: { + argName: subject + maxLength: 72 + submit: true + text: New user feedback + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + newUserInactiveDone: { + desc: Finished with NUA + art: DONE + config: { pause: true } + next: @menu:logoff + } + + fullLoginSequenceLoginArt: { + desc: Logging In + art: WELCOME + config: { pause: true } + next: fullLoginSequenceLastCallers + } + + fullLoginSequenceLastCallers: { + desc: Last Callers + module: last_callers + art: LASTCALL + config: { + pause: true + font: cp437 + } + next: fullLoginSequenceWhosOnline + } + fullLoginSequenceWhosOnline: { + desc: Who's Online + module: whos_online + art: WHOSON + config: { pause: true } + next: fullLoginSequenceOnelinerz + } + + fullLoginSequenceOnelinerz: { + desc: Viewing Onelinerz + module: onelinerz + next: [ + { + // calls >= 2 + acs: NC2 + next: fullLoginSequenceNewScanConfirm + } + { + // new users - skip new scan + next: fullLoginSequenceUserStats + } + ] + config: { + cls: true + art: { + view: ONELINER + add: ONEADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: oneliner + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + fullLoginSequenceNewScanConfirm: { + desc: Logging In + prompt: loginGlobalNewScan + submit: [ + { + value: { promptValue: 0 } + action: @menu:fullLoginSequenceNewScan + } + { + value: { promptValue: 1 } + action: @menu:fullLoginSequenceUserStats + } + ] + } + + fullLoginSequenceNewScan: { + desc: Performing New Scan + module: new_scan + art: NEWSCAN + next: fullLoginSequenceSysStats + config: { + messageListMenu: newScanMessageList + } + } + + fullLoginSequenceSysStats: { + desc: System Stats + art: SYSSTAT + config: { pause: true } + next: fullLoginSequenceUserStats + } + fullLoginSequenceUserStats: { + desc: User Stats + art: STATUS + config: { pause: true } + next: mainMenu + } + + newScanMessageList: { + desc: New Messages + module: msg_list + art: NEWMSGS + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + TL6: { + // theme me! + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "x", "shift + x" ] + action: @method:fullExit + } + { + keys: [ "m", "shift + m" ] + action: @method:markAllRead + } + ] + } + } + } + + newScanFileBaseList: { + module: file_area_list + desc: New Files + config: { + art: { + browse: FNEWBRWSE + details: FDETAIL + detailsGeneral: FDETGEN + detailsNfo: FDETNFO + detailsFileList: FDETLST + help: FBHELP + } + } + form: { + 0: { + mci: { + MT1: { + mode: preview + ansiView: true + } + + HM2: { + focus: true + submit: true + argName: navSelect + items: [ + "prev", "next", "details", "toggle queue", "rate", "help", "quit" + ] + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFile + } + { + value: { navSelect: 1 } + action: @method:nextFile + } + { + value: { navSelect: 2 } + action: @method:viewDetails + } + { + value: { navSelect: 3 } + action: @method:toggleQueue + } + { + value: { navSelect: 4 } + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + value: { navSelect: 5 } + action: @method:displayHelp + } + { + value: { navSelect: 6 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "w", "shift + w" ] + action: @method:showWebDownloadLink + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "t", "shift + t" ] + action: @method:toggleQueue + } + { + keys: [ "v", "shift + v" ] + action: @method:viewDetails + } + { + keys: [ "r", "shift + r" ] + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + keys: [ "?" ] + action: @method:displayHelp + } + ] + } + + 1: { + mci: { + HM1: { + focus: true + submit: true + argName: navSelect + items: [ + "general", "nfo/readme", "file listing" + ] + } + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @method:detailsQuit + } + ] + } + + 2: { + // details - general + mci: {} + } + + 3: { + // details - nfo/readme + mci: { + MT1: { + mode: preview + } + } + } + + 4: { + // details - file listing + mci: { + VM1: { + + } + } + } + } + } + + /////////////////////////////////////////////////////////////////////// + // Main Menu + /////////////////////////////////////////////////////////////////////// + mainMenu: { + art: MMENU + desc: Main Menu + prompt: menuCommand + config: { + font: cp437 + interrupt: realtime + } + submit: [ + { + value: { command: "MSG" } + action: @menu:nodeMessage + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: { command: "D" } + action: @menu:doorMenu + } + { + value: { command: "F" } + action: @menu:fileBase + } + { + value: { command: "U" } + action: @menu:mainMenuUserList + } + { + value: { command: "L" } + action: @menu:mainMenuLastCallers + } + { + value: { command: "W" } + action: @menu:mainMenuWhosOnline + } + { + value: { command: "Y" } + action: @menu:mainMenuUserStats + } + { + value: { command: "M" } + action: @menu:messageArea + } + { + value: { command: "E" } + action: @menu:mailMenu + } + { + value: { command: "C" } + action: @menu:mainMenuUserConfig + } + { + value: { command: "S" } + action: @menu:mainMenuSystemStats + } + { + value: { command: "!" } + action: @menu:mainMenuGlobalNewScan + } + { + value: { command: "K" } + action: @menu:mainMenuFeedbackToSysOp + } + { + value: { command: "O" } + action: @menu:mainMenuOnelinerz + } + { + value: { command: "R" } + action: @menu:mainMenuRumorz + } + { + value: { command: "BBS"} + action: @menu:bbsList + } + { + value: { command: "UA" } + action: @menu:mainMenuUserAchievementsEarned + } + { + value: 1 + action: @menu:mainMenu + } + ] + } + + mainMenuUserAchievementsEarned: { + desc: Achievements + module: user_achievements_earned + art: USERACHIEV + form: { + 0: { + mci: { + VM1: { + focus: true + } + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + nodeMessage: { + desc: Node Messaging + module: node_msg + art: NODEMSG + config: { + cls: true + art: { + header: NODEMSGHDR + footer: NODEMSGFTR + } + } + form: { + 0: { + mci: { + SM1: { + argName: node + } + ET2: { + argName: message + submit: true + } + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + submit: { + *: [ + { + value: { message: null } + action: @method:sendMessage + } + ] + } + } + } + } + + mainMenuLastCallers: { + desc: Last Callers + module: last_callers + art: LASTCALL + config: { pause: true } + } + + mainMenuWhosOnline: { + desc: Who's Online + module: whos_online + art: WHOSON + config: { pause: true } + } + + mainMenuUserStats: { + desc: User Stats + art: STATUS + config: { pause: true } + } + + mainMenuSystemStats: { + desc: System Stats + art: SYSSTAT + config: { pause: true } + } + + mainMenuUserList: { + desc: User Listing + module: user_list + art: USERLST + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + } + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + mainMenuUserConfig: { + module: user_config + art: CONFSCR + form: { + 0: { + mci: { + ET1: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + focus: true + } + ME2: { + argName: birthdate + maskPattern: "####/##/##" + } + ME3: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET4: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: affils + maxLength: @config:users.affilsMax + } + ET6: { + argName: email + maxLength: @config:users.emailMax + validate: @method:validateEmailAvail + } + ET7: { + argName: web + maxLength: @config:users.webMax + } + ME8: { + maskPattern: "##" + argName: termHeight + validate: @systemMethod:validateNonEmpty + } + SM9: { + argName: theme + } + ET10: { + argName: password + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassword + } + ET11: { + argName: passwordConfirm + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassConfirmMatch + } + TM25: { + argName: submission + items: [ "save", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { submission: 0 } + action: @method:saveChanges + } + { + value: { submission: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + mainMenuGlobalNewScan: { + desc: Performing New Scan + module: new_scan + art: NEWSCAN + config: { + messageListMenu: newScanMessageList + } + } + + mainMenuFeedbackToSysOp: { + desc: Feedback to SysOp + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + toUserId: 1 /* always to +op */ + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: @sysStat:sysop_username + // :TODO: readOnly: true + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + mainMenuOnelinerz: { + desc: Viewing Onelinerz + module: onelinerz + config: { + cls: true + art: { + view: ONELINER + add: ONEADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: oneliner + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + mainMenuRumorz: { + desc: Rumorz + module: rumorz + config: { + cls: true + art: { + entries: RUMORS + add: RUMORADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: rumor + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + bbsList: { + desc: Viewing BBS List + module: bbs_list + config: { + cls: true + art: { + entries: BBSLIST + add: BBSADD + } + } + + form: { + 0: { + mci: { + VM1: { maxLength: 32 } + TL2: { maxLength: 32 } + TL3: { maxLength: 32 } + TL4: { maxLength: 32 } + TL5: { maxLength: 32 } + TL6: { maxLength: 32 } + TL7: { maxLength: 32 } + TL8: { maxLength: 32 } + TL9: { maxLength: 32 } + } + actionKeys: [ + { + keys: [ "a" ] + action: @method:addBBS + } + { + // :TODO: add delete key + keys: [ "d" ] + action: @method:deleteBBS + } + { + keys: [ "q", "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + ET1: { + argName: name + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET2: { + argName: sysop + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET3: { + argName: telnet + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET4: { + argName: www + maxLength: 32 + } + ET5: { + argName: location + maxLength: 32 + } + ET6: { + argName: software + maxLength: 32 + } + ET7: { + argName: notes + maxLength: 32 + } + TM17: { + argName: submission + items: [ "save", "cancel" ] + submit: true + } + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelSubmit + } + ] + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitBBS + } + { + value: { "submission" : 1 } + action: @method:cancelSubmit + } + ] + } + } + } + } + + /////////////////////////////////////////////////////////////////////// + // Doors Menu + /////////////////////////////////////////////////////////////////////// + doorMenu: { + desc: Doors Menu + art: DOORMNU + prompt: menuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "G" } + action: @menu:logoff + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + // + // The system supports many ways of launching doors including + // modules for DoorParty!, BBSLink, etc. + // + // Below are some examples. See the documentation for more info. + // + { + value: { command: "ABRACADABRA" } + action: @menu:doorAbracadabraExample + } + { + value: { command: "TWBBSLINK" } + action: @menu:doorTradeWars2002BBSLinkExample + } + { + value: { command: "DP" } + action: @menu:doorPartyExample + } + { value: { command: "CN" } action: @menu:doorCombatNetExample } - { - value: { command: "EXODUS" } - action: @menu:doorExodusCataclysm - } - ] - } + { + value: { command: "EXODUS" } + action: @menu:doorExodusCataclysm + } + ] + } - // - // Local Door Example via abracadabra module - // - // This example assumes launch_door.sh (which is passed args) - // launches the door. - // - doorAbracadabraExample: { - desc: Abracadabra Example - module: abracadabra - config: { - name: Example Door - dropFileType: DORINFO - cmd: /home/enigma/DOS/scripts/launch_door.sh - args: [ - "{node}", - "{dropFile}", - "{srvPort}", - ], - nodeMax: 1 - tooManyArt: DOORMANY - io: socket - } - } - - // - // BBSLink Example (TradeWars 2000) - // - // Register @ https://bbslink.net/ - // - doorTradeWars2002BBSLinkExample: { - desc: Playing TW 2002 (BBSLink) - module: bbs_link - config: { - sysCode: XXXXXXXX - authCode: XXXXXXXX - schemeCode: XXXXXXXX - door: tw - } - } - - // - // DoorParty! Example - // - // Register @ http://throwbackbbs.com/ - // - doorPartyExample: { - desc: Using DoorParty! - module: door_party - config: { - username: XXXXXXXX - password: XXXXXXXX - bbsTag: XX - } - } - - // - // CombatNet Example // - // Register @ http://combatnet.us/ + // Local Door Example via abracadabra module + // + // This example assumes launch_door.sh (which is passed args) + // launches the door. + // + doorAbracadabraExample: { + desc: Abracadabra Example + module: abracadabra + config: { + name: Example Door + dropFileType: DORINFO + cmd: /home/enigma/DOS/scripts/launch_door.sh + args: [ + "{node}", + "{dropFile}", + "{srvPort}", + ], + nodeMax: 1 + tooManyArt: DOORMANY + io: socket + } + } + + // + // BBSLink Example (TradeWars 2000) + // + // Register @ https://bbslink.net/ + // + doorTradeWars2002BBSLinkExample: { + desc: Playing TW 2002 (BBSLink) + module: bbs_link + config: { + sysCode: XXXXXXXX + authCode: XXXXXXXX + schemeCode: XXXXXXXX + door: tw + } + } + + // + // DoorParty! Example + // + // Register @ http://throwbackbbs.com/ + // + doorPartyExample: { + desc: Using DoorParty! + module: door_party + config: { + username: XXXXXXXX + password: XXXXXXXX + bbsTag: XX + } + } + + // + // CombatNet Example + // + // Register @ http://combatnet.us/ // doorCombatNetExample: { desc: Using CombatNet @@ -1765,2316 +1790,2317 @@ } // - // Exodus Example (cataclysm) - // Register @ https://oddnetwork.org/exodus/ + // Exodus Example (cataclysm) + // Register @ https://oddnetwork.org/exodus/ // doorExodusCataclysm: { - desc: Cataclysm - module: exodus - config: { - rejectUnauthorized: false - board: XXX - key: XXXXXXXX - door: cataclysm - } - } - - /////////////////////////////////////////////////////////////////////// - // Message Area Menu - /////////////////////////////////////////////////////////////////////// - messageArea: { - art: MSGMNU - desc: Message Area - prompt: messageMenuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { command: "P" } - action: @menu:messageAreaNewPost - } - { - value: { command: "J" } - action: @menu:messageAreaChangeCurrentConference - } - { - value: { command: "C" } - action: @menu:messageAreaChangeCurrentArea - } - { - value: { command: "L" } - action: @menu:messageAreaMessageList - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: { command: "<" } - action: @systemMethod:prevConf - } - { - value: { command: ">" } - action: @systemMethod:nextConf - } - { - value: { command: "[" } - action: @systemMethod:prevArea - } - { - value: { command: "]" } - action: @systemMethod:nextArea - } - { - value: { command: "D" } - action: @menu:messageAreaSetNewScanDate - } - { - value: { command: "S" } - action: @menu:messageSearch - } - { - value: 1 - action: @menu:messageArea - } - ] - } - - messageSearch: { - desc: Message Search - module: message_base_search - art: MSEARCH - config: { - messageListMenu: messageAreaSearchMessageList - } - form: { - 0: { - mci: { - ET1: { - focus: true - argName: searchTerms - } - BT2: { - argName: search - text: search - submit: true - } - SM3: { - argName: confTag - } - SM4: { - argName: areaTag - } - ET5: { - argName: toUserName - maxLength: @config:users.usernameMax - } - ET6: { - argName: fromUserName - maxLength: @config:users.usernameMax - } - BT7: { - argName: advancedSearch - text: advanced search - submit: true - } - } - - submit: { - *: [ - { - value: { search: null } - action: @method:search - } - { - value: { advancedSearch: null } - action: @method:search - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaSearchMessageList: { - desc: Message Search - module: msg_list - art: MSRCHLST - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - TL6: { - // theme me! - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageSearchNoResults: { - desc: Message Search - art: MSRCNORES - config: { - pause: true - } - } - - messageAreaChangeCurrentConference: { - art: CCHANGE - module: msg_conf_list - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: conf - } - } - submit: { - *: [ - { - value: { conf: null } - action: @method:changeConference - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaSetNewScanDate: { - module: set_newscan_date - desc: Message Base - art: SETMNSDATE - config: { - target: message - scanDateFormat: YYYYMMDD - } - form: { - 0: { - mci: { - ME1: { - focus: true - submit: true - argName: scanDate - maskPattern: "####/##/##" - } - SM2: { - argName: targetSelection - submit: false - } - } - submit: { - *: [ - { - value: { scanDate: null } - action: @method:scanDateSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - changeMessageConfPreArt: { - module: show_art - config: { - method: messageConf - key: confTag - pause: true - cls: true - menuFlags: [ "popParent", "noHistory" ] - } - } - - messageAreaChangeCurrentArea: { - // :TODO: rename this art to ACHANGE - art: CHANGE - module: msg_area_list - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: area - } - } - submit: { - *: [ - { - value: { area: null } - action: @method:changeArea - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - changeMessageAreaPreArt: { - module: show_art - config: { - method: messageArea - key: areaTag - pause: true - cls: true - menuFlags: [ "popParent", "noHistory" ] - } - } - - messageAreaMessageList: { - module: msg_list - art: MSGLIST - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaViewPost: { - module: msg_area_view_fse - config: { - art: { - header: MSGVHDR - body: MSGBODY - footerView: MSGVFTR - help: MSGVHLP - }, - editorMode: view - editorType: area - } - form: { - 0: { - mci: { - // :TODO: ensure this block isn't even req. for theme to apply... - } - } - 1: { - mci: { - MT1: { - width: 79 - mode: preview - } - } - submit: { - *: [ - { - value: message - action: @method:editModeEscPressed - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - } - 2: { - TLTL: { - mci: { - TL1: { width: 5 } - TL2: { width: 4 } - } - } - } - 4: { - mci: { - HM1: { - // :TODO: (#)Jump/(L)Index (msg list)/Last - items: [ "prev", "next", "reply", "quit", "help" ] - focusItemIndex: 1 - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:prevMessage - } - { - value: { 1: 1 } - action: @method:nextMessage - } - { - value: { 1: 2 } - action: @method:replyMessage - extraArgs: { - menu: messageAreaReplyPost - } - } - { - value: { 1: 3 } - action: @systemMethod:prevMenu - } - { - value: { 1: 4 } - action: @method:viewModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "p", "shift + p" ] - action: @method:prevMessage - } - { - keys: [ "n", "shift + n" ] - action: @method:nextMessage - } - { - keys: [ "r", "shift + r" ] - action: @method:replyMessage - extraArgs: { - menu: messageAreaReplyPost - } - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "?" ] - action: @method:viewModeMenuHelp - } - { - keys: [ "down arrow", "up arrow", "page up", "page down" ] - action: @method:movementKeyPressed - } - ] - } - } - } - - messageAreaReplyPost: { - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - quote: MSGQUOT - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - } - editorMode: edit - editorType: area - } - form: { - 0: { - mci: { - // :TODO: use appropriate system properties for max lengths - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateNonEmpty - } - TL4: { - // :TODO: this is for RE: line (NYI) - //width: 27 - //textOverflow: ... - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - height: 14 - argName: message - mode: edit - } - } - submit: { - *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ], - viewId: 1 - } - ] - } - - 3: { - mci: { - HM1: { - items: [ "save", "discard", "quote", "help" ] - } - } - - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 }, - action: @method:editModeMenuQuote - } - { - value: { 1: 3 } - action: @method:editModeMenuHelp - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "s", "shift + s" ] - action: @method:editModeMenuSave - } - { - keys: [ "d", "shift + d" ] - action: @systemMethod:prevMenu - } - { - keys: [ "q", "shift + q" ] - action: @method:editModeMenuQuote - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - - // Quote builder - 5: { - mci: { - MT1: { - width: 79 - height: 7 - } - VM3: { - width: 79 - height: 4 - argName: quote - } - } - - submit: { - *: [ - { - value: { quote: null } - action: @method:appendQuoteEntry - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:quoteBuilderEscPressed - } - ] - } - } - } - // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu - messageAreaNewPost: { - desc: Posting message, - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - } - editorMode: edit - editorType: area - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: All - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateNonEmpty - // :TODO: Validate -> close/cancel if empty - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - - 1: { - "mci" : { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - } - 2: { - TLTL: { - mci: { - TL1: { width: 5 } - TL2: { width: 4 } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - "items" : [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - // :TODO: something like the following for overriding keymap - // this should only override specified entries. others will default - /* - "keyMap" : { - "accept" : [ "return" ] - } - */ - } - } - } - } - - - // - // User to User mail aka Email Menu - // - mailMenu: { - art: MAILMNU - desc: Mail Menu - prompt: menuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { command: "C" } - action: @menu:mailMenuCreateMessage - } - { - value: { command: "I" } - action: @menu:mailMenuInbox - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: 1 - action: @menu:mailMenu - } - ] - } - - mailMenuCreateMessage: { - desc: Mailing Someone - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - validate: @systemMethod:validateGeneralMailAddressedTo - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - mailMenuInbox: { - module: msg_list - art: PRVMSGLIST - config: { - menuViewPost: messageAreaViewPost - messageAreaTag: private_mail - } - form: { - 0: { // main list - mci: { - VM1: { - focus: true - submit: true - argName: message - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "delete", "d", "shift + d" ] - action: @method:deleteSelected - } - ] - } - 1: { // delete prompt form - submit: { - *: [ - { - value: { promptValue: 0 } - action: @method:deleteMessageYes - } - { - value: { promptValue: 1 } - action: @method:deleteMessageNo - } - ] - } - } - } - } - - //////////////////////////////////////////////////////////////////////// - // File Base - //////////////////////////////////////////////////////////////////////// - - fileBase: { - desc: File Base - art: FMENU - prompt: fileMenuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { menuOption: "L" } - action: @menu:fileBaseListEntries - } - { - value: { menuOption: "B" } - action: @menu:fileBaseBrowseByAreaSelect - } - { - value: { menuOption: "F" } - action: @menu:fileAreaFilterEditor - } - { - value: { menuOption: "Q" } - action: @systemMethod:prevMenu - } - { - value: { menuOption: "G" } - action: @menu:fullLogoffSequence - } - { - value: { menuOption: "D" } - action: @menu:fileBaseDownloadManager - } - { - value: { menuOption: "W" } - action: @menu:fileBaseWebDownloadManager - } - { - value: { menuOption: "U" } - action: @menu:fileBaseUploadFiles - } - { - value: { menuOption: "S" } - action: @menu:fileBaseSearch - } - { - value: { menuOption: "P" } - action: @menu:fileBaseSetNewScanDate - } - { - value: { menuOption: "E" } - action: @menu:fileBaseExportListFilter - } - ] - } - - fileBaseExportListFilter: { - module: file_base_search - // :TODO: fixme: - art: FSEARCH - config: { - fileBaseListEntriesMenu: fileBaseExportList - } - form: { - 0: { - mci: { - ET1: { - focus: true - argName: searchTerms - } - BT2: { - argName: search - text: search - submit: true - } - ET3: { - maxLength: 64 - argName: tags - } - SM4: { - maxLength: 64 - argName: areaIndex - } - SM5: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - "filename" - ] - argName: sortByIndex - } - SM6: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - BT7: { - argName: advancedSearch - text: advanced search - submit: true - } - } - - submit: { - *: [ - { - value: { search: null } - action: @method:search - } - { - value: { advancedSearch: null } - action: @method:search - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseExportList: { - module: file_base_user_list_export - art: FBLISTEXP - config: { - pause: true - templates: { - entry: file_list_entry.asc - } - } - form: { - 0: { - mci: { - TL1: { } - TL2: { } - } - } - } - } - - fileBaseExportListNoResults: { - desc: Browsing Files - art: FBNORES - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - fileBaseSetNewScanDate: { - module: set_newscan_date - desc: File Base - art: SETFNSDATE - config: { - target: file - scanDateFormat: YYYYMMDD - } - form: { - 0: { - mci: { - ME1: { - focus: true - submit: true - argName: scanDate - maskPattern: "####/##/##" - } - } - submit: { - *: [ - { - value: { scanDate: null } - action: @method:scanDateSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseListEntries: { - module: file_area_list - desc: Browsing Files - config: { - art: { - browse: FBRWSE - details: FDETAIL - detailsGeneral: FDETGEN - detailsNfo: FDETNFO - detailsFileList: FDETLST - help: FBHELP - } - } - form: { - 0: { - mci: { - MT1: { - mode: preview - } - - HM2: { - focus: true - submit: true - argName: navSelect - items: [ - "prev", "next", "details", "toggle queue", "rate", "change filter", "help", "quit" - ] - focusItemIndex: 1 - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFile - } - { - value: { navSelect: 1 } - action: @method:nextFile - } - { - value: { navSelect: 2 } - action: @method:viewDetails - } - { - value: { navSelect: 3 } - action: @method:toggleQueue - } - { - value: { navSelect: 4 } - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - value: { navSelect: 5 } - action: @menu:fileAreaFilterEditor - } - { - value: { navSelect: 6 } - action: @method:displayHelp - } - { - value: { navSelect: 7 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "w", "shift + w" ] - action: @method:showWebDownloadLink - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "t", "shift + t" ] - action: @method:toggleQueue - } - { - keys: [ "f", "shift + f" ] - action: @menu:fileAreaFilterEditor - } - { - keys: [ "v", "shift + v" ] - action: @method:viewDetails - } - { - keys: [ "r", "shift + r" ] - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - keys: [ "?" ] - action: @method:displayHelp - } - ] - } - - 1: { - mci: { - HM1: { - focus: true - submit: true - argName: navSelect - items: [ - "general", "nfo/readme", "file listing" - ] - } - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @method:detailsQuit - } - ] - } - - 2: { - // details - general - mci: {} - } - - 3: { - // details - nfo/readme - mci: { - MT1: { - mode: preview - } - } - } - - 4: { - // details - file listing - mci: { - VM1: { - - } - } - } - } - } - - fileBaseBrowseByAreaSelect: { - desc: Browsing File Areas - module: file_base_area_select - art: FAREASEL - form: { - 0: { - mci: { - VM1: { - focus: true - argName: areaTag - } - } - - submit: { - *: [ - { - value: { areaTag: null } - action: @method:selectArea - } - ] - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseGetRatingForSelectedEntry: { - desc: Rating a File - prompt: fileBaseRateEntryPrompt - config: { - cls: true - } - submit: [ - // :TODO: handle esc/q - { - // pass data back to caller - value: { rating: null } - action: @systemMethod:prevMenu - } - ] - } - - fileBaseListEntriesNoResults: { - desc: Browsing Files - art: FBNORES - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - fileBaseSearch: { - module: file_base_search - desc: Searching Files - art: FSEARCH - form: { - 0: { - mci: { - ET1: { - focus: true - argName: searchTerms - } - BT2: { - argName: search - text: search - submit: true - } - ET3: { - maxLength: 64 - argName: tags - } - SM4: { - maxLength: 64 - argName: areaIndex - } - SM5: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - "filename", - ] - argName: sortByIndex - } - SM6: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - BT7: { - argName: advancedSearch - text: advanced search - submit: true - } - } - - submit: { - *: [ - { - value: { search: null } - action: @method:search - } - { - value: { advancedSearch: null } - action: @method:search - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileAreaFilterEditor: { - desc: File Filter Editor - module: file_area_filter_edit - art: FFILEDT - form: { - 0: { - mci: { - ET1: { - argName: searchTerms - } - ET2: { - maxLength: 64 - argName: tags - } - SM3: { - maxLength: 64 - argName: areaIndex - } - SM4: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - ] - argName: sortByIndex - } - SM5: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - ET6: { - maxLength: 64 - argName: name - validate: @systemMethod:validateNonEmpty - } - HM7: { - focus: true - items: [ - "prev", "next", "make active", "save", "new", "delete" - ] - argName: navSelect - focusItemIndex: 1 - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFilter - } - { - value: { navSelect: 1 } - action: @method:nextFilter - } - { - value: { navSelect: 2 } - action: @method:makeFilterActive - } - { - value: { navSelect: 3 } - action: @method:saveFilter - } - { - value: { navSelect: 4 } - action: @method:newFilter - } - { - value: { navSelect: 5 } - action: @method:deleteFilter - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseDownloadManager: { - desc: Download Manager - module: file_base_download_manager - config: { - art: { - queueManager: FDLMGR - /* - NYI - details: FDLDET - */ - } - emptyQueueMenu: fileBaseDownloadManagerEmptyQueue - } - form: { - 0: { - mci: { - VM1: { - argName: queueItem - } - HM2: { - focus: true - items: [ "download all", "quit" ] - argName: navSelect - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:downloadAll - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "a", "shift + a" ] - action: @method:downloadAll - } - { - keys: [ "delete", "r", "shift + r" ] - action: @method:removeItem - } - { - keys: [ "c", "shift + c" ] - action: @method:clearQueue - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseWebDownloadManager: { - desc: Web D/L Manager - module: file_base_web_download_manager - config: { - art: { - queueManager: FWDLMGR - batchList: BATDLINF - } - emptyQueueMenu: fileBaseDownloadManagerEmptyQueue - } - form: { - 0: { - mci: { - VM1: { - argName: queueItem - } - HM2: { - focus: true - items: [ "get batch link", "quit", "help" ] - argName: navSelect - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:getBatchLink - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "b", "shift + b" ] - action: @method:getBatchLink - } - { - keys: [ "delete", "r", "shift + r" ] - action: @method:removeItem - } - { - keys: [ "c", "shift + c" ] - action: @method:clearQueue - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseDownloadManagerEmptyQueue: { - desc: Empty Download Queue - art: FEMPTYQ - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - fileTransferProtocolSelection: { - desc: Protocol selection - module: file_transfer_protocol_select - art: FPROSEL - form: { - 0: { - mci: { - VM1: { - focus: true - argName: protocol - } - } - - submit: { - *: [ - { - value: { protocol: null } - action: @method:selectProtocol - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseUploadFiles: { - desc: Uploading - module: upload - config: { - art: { - options: ULOPTS - fileDetails: ULDETAIL - processing: ULCHECK - dupes: ULDUPES - } - } - - form: { - // options - 0: { - mci: { - SM1: { - argName: areaSelect - focus: true - } - TM2: { - argName: uploadType - items: [ "blind", "supply filename" ] - } - ET3: { - argName: fileName - maxLength: 255 - validate: @method:validateNonBlindFileName - } - HM4: { - argName: navSelect - items: [ "continue", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:optionsNavContinue - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - "actionKeys" : [ - { - "keys" : [ "escape" ], - action: @systemMethod:prevMenu - } - ] - } - - 1: { - mci: { } - } - - // file details entry - 2: { - mci: { - MT1: { - argName: shortDesc - tabSwitchesView: true - focus: true - } - - ET2: { - argName: tags - } - - ME3: { - argName: estYear - maskPattern: "####" - } - - BT4: { - argName: continue - text: continue - submit: true - } - } - - submit: { - *: [ - { - value: { continue: null } - action: @method:fileDetailsContinue - } - ] - } - } - - // dupes - 3: { - mci: { - VM1: { - /* - Use 'dupeInfoFormat' to custom format: - - areaDesc - areaName - areaTag - desc - descLong - fileId - fileName - fileSha256 - storageTag - uploadTimestamp - - */ - - mode: preview - } - } - } - } - } - - fileBaseNoUploadAreasAvail: { - desc: File Base - art: ULNOAREA - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - sendFilesToUser: { - desc: Downloading - module: file_transfer - config: { - // defaults - generally use extraArgs - protocol: zmodem8kSexyz - direction: send - } - } - - recvFilesFromUser: { - desc: Uploading - module: file_transfer - config: { - // defaults - generally use extraArgs - protocol: zmodem8kSexyz - direction: recv - } - } - - - //////////////////////////////////////////////////////////////////////// - // Required entries - //////////////////////////////////////////////////////////////////////// - idleLogoff: { - art: IDLELOG - next: @systemMethod:logoff - } - //////////////////////////////////////////////////////////////////////// - // Demo Section - // :TODO: This entire section needs updated!!! - //////////////////////////////////////////////////////////////////////// - "demoMain" : { - "art" : "demo_selection_vm.ans", - "form" : { - "0" : { - "VM" : { - "mci" : { - "VM1" : { - "items" : [ - "Single Line Text Editing Views", - "Spinner & Toggle Views", - "Mask Edit Views", - "Multi Line Text Editor", - "Vertical Menu Views", - "Horizontal Menu Views", - "Art Display", - "Full Screen Editor" - ], - "height" : 10, - "itemSpacing" : 1, - "justify" : "center", - "focusTextStyle" : "small i" - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@menu:demoEditTextView" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoSpinAndToggleView" - }, - { - "value" : { "1" : 2 }, - "action" : "@menu:demoMaskEditView" - }, - { - "value" : { "1" : 3 }, - "action" : "@menu:demoMultiLineEditTextView" - }, - { - "value" : { "1" : 4 }, - "action" : "@menu:demoVerticalMenuView" - }, - { - "value" : { "1" : 5 }, - "action" : "@menu:demoHorizontalMenuView" - }, - { - "value" : { "1" : 6 }, - "action" : "@menu:demoArtDisplay" - }, - { - "value" : { "1" : 7 }, - "action" : "@menu:demoFullScreenEditor" - } - ] - } - } - } - } - }, - "demoEditTextView" : { - "art" : "demo_edit_text_view1.ans", - "form" : { - "0" : { - "BTETETETET" : { - "mci" : { - "ET1" : { - "width" : 20, - "maxLength" : 20 - }, - "ET2" : { - "width" : 20, - "maxLength" : 40, - "textOverflow" : "..." - }, - "ET3" : { - "width" : 20, - "fillChar" : "-", - "styleSGR1" : "|00|36", - "maxLength" : 20 - }, - "ET4" : { - "width" : 20, - "maxLength" : 20, - "password" : true - }, - "BT5" : { - "width" : 8, - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoSpinAndToggleView" : { - "art" : "demo_spin_and_toggle.ans", - "form" : { - "0" : { - "BTSMSMTM" : { - "mci" : { - "SM1" : { - "items" : [ "Henry Morgan", "François l'Ollonais", "Roche Braziliano", "Black Bart", "Blackbeard" ] - }, - "SM2" : { - "items" : [ "Razor 1911", "DrinkOrDie", "TRSI" ] - }, - "TM3" : { - "items" : [ "Yarly", "Nowaii" ], - "styleSGR1" : "|00|30|01", - "hotKeys" : { "Y" : 0, "N" : 1 } - }, - "BT8" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 8, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 8 - } - ] - } - } - } - }, - "demoMaskEditView" : { - "art" : "demo_mask_edit_text_view1.ans", - "form" : { - "0" : { - "BTMEME" : { - "mci" : { - "ME1" : { - "maskPattern" : "##/##/##", - "styleSGR1" : "|00|30|01", - //"styleSGR2" : "|00|45|01", - "styleSGR3" : "|00|30|35", - "fillChar" : "#" - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoMultiLineEditTextView" : { - "art" : "demo_multi_line_edit_text_view1.ans", - "form" : { - "0" : { - "BTMT" : { - "mci" : { - "MT1" : { - "width" : 70, - "height" : 17, - //"text" : "@art:demo_multi_line_edit_text_view_text.txt", - // "text" : "@systemMethod:textFromFile" - text: "Hints:\n\t* Insert / CTRL-V toggles overtype mode\n\t* CTRL-Y deletes the current line\n\t* Try Page Up / Page Down\n\t* Home goes to the start of line text\n\t* End goes to the end of a line\n\n\nTab handling:\n-------------------------------------------------\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\nA0\tBB\t1\tCCC\t2\tDDD\t3EEEE\nW\t\tX\t\tY\t\tZ\n\nAn excerpt from A Clockwork Orange:\n\"What sloochatted then, of course, was that my cellmates woke up and started joining in, tolchocking a bit wild in the near-dark, and the shoom seemed to wake up the whole tier, so that you could slooshy a lot of creeching and banging about with tin mugs on the wall, as though all the plennies in all the cells thought a big break was about to commence, O my brothers.\n", - "focus" : true - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoHorizontalMenuView" : { - "art" : "demo_horizontal_menu_view1.ans", - "form" : { - "0" : { - "BTHMHM" : { - "mci" : { - "HM1" : { - "items" : [ "One", "Two", "Three" ], - "hotKeys" : { "1" : 0, "2" : 1, "3" : 2 } - }, - "HM2" : { - "items" : [ "Uno", "Dos", "Tres" ], - "hotKeys" : { "U" : 0, "D" : 1, "T" : 2 } - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoVerticalMenuView" : { - "art" : "demo_vertical_menu_view1.ans", - "form" : { - "0" : { - "BTVM" : { - "mci" : { - "VM1" : { - "items" : [ - "|33Oblivion/2", - "|33iNiQUiTY", - "|33ViSiON/X" - ], - "focusItems" : [ - "|33Oblivion|01/|00|332", - "|01|33i|00|33N|01i|00|33QU|01i|00|33TY", - "|33ViSiON/X" - ] - // - // :TODO: how to do the following: - // 1) Supply a view a string for a standard vs focused item - // "items" : [...], "focusItems" : [ ... ] ? - // "draw" : "@method:drawItemX", then items: [...] - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - - }, - "demoArtDisplay" : { - "art" : "demo_selection_vm.ans", - "form" : { - "0" : { - "VM" : { - "mci" : { - "VM1" : { - "items" : [ - "Defaults - DOS ANSI", - "bw_mindgames.ans - DOS", - "test.ans - DOS", - "Defaults - Amiga", - "Pause at Term Height" - ], - // :TODO: justify not working?? - "focusTextStyle" : "small i" - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@menu:demoDefaultsDosAnsi" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoDefaultsDosAnsi_bw_mindgames" - }, - { - "value" : { "1" : 2 }, - "action" : "@menu:demoDefaultsDosAnsi_test" - } - ] - } - } - } - } - }, - "demoDefaultsDosAnsi" : { - "art" : "DM-ENIG2.ANS" - }, - "demoDefaultsDosAnsi_bw_mindgames" : { - "art" : "bw_mindgames.ans" - }, - "demoDefaultsDosAnsi_test" : { - "art" : "test.ans" - }, - "demoFullScreenEditor" : { - "module" : "fse", - "config" : { - "editorType" : "netMail", - "art" : { - "header" : "demo_fse_netmail_header.ans", - "body" : "demo_fse_netmail_body.ans", - "footerEditor" : "demo_fse_netmail_footer_edit.ans", - "footerEditorMenu" : "demo_fse_netmail_footer_edit_menu.ans", - "footerView" : "demo_fse_netmail_footer_view.ans", - "help" : "demo_fse_netmail_help.ans" - } - }, - "form" : { - "0" : { - "ETETET" : { - "mci" : { - "ET1" : { - // :TODO: from/to may be set by args - // :TODO: focus may change dep on view vs edit - "width" : 36, - "focus" : true, - "argName" : "to" - }, - "ET2" : { - "width" : 36, - "argName" : "from" - }, - "ET3" : { - "width" : 65, - "maxLength" : 72, - "submit" : [ "enter" ], - "argName" : "subject" - } - }, - "submit" : { - "3" : [ - { - "value" : { "subject" : null }, - "action" : "@method:headerSubmit" - } - ] - } - } - }, - "1" : { - "MT" : { - "mci" : { - "MT1" : { - "width" : 79, - "height" : 17, - "text" : "", // :TODO: should not be req. - "argName" : "message" - } - }, - "submit" : { - "*" : [ - { - "value" : "message", - "action" : "@method:editModeEscPressed" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 1 - } - ] - } - }, - "2" : { - "TLTL" : { - "mci" : { - "TL1" : { - "width" : 5 - }, - "TL2" : { - "width" : 4 - } - } - } - }, - "3" : { - "HM" : { - "mci" : { - "HM1" : { - // :TODO: Continue, Save, Discard, Clear, Quote, Help - "items" : [ "Save", "Discard", "Quote", "Help" ] - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@method:editModeMenuSave" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoMain" - }, - { - "value" : { "1" : 2 }, - "action" : "@method:editModeMenuQuote" - }, - { - "value" : { "1" : 3 }, - "action" : "@method:editModeMenuHelp" - }, - { - "value" : 1, - "action" : "@method:editModeEscPressed" - } - ] - }, - "actionKeys" : [ // :TODO: Need better name - { - "keys" : [ "escape" ], - "action" : "@method:editModeEscPressed" - } - ] - } - } - } - } - } + desc: Cataclysm + module: exodus + config: { + rejectUnauthorized: false + board: XXX + key: XXXXXXXX + door: cataclysm + } + } + + /////////////////////////////////////////////////////////////////////// + // Message Area Menu + /////////////////////////////////////////////////////////////////////// + messageArea: { + art: MSGMNU + desc: Message Area + prompt: messageMenuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "P" } + action: @menu:messageAreaNewPost + } + { + value: { command: "J" } + action: @menu:messageAreaChangeCurrentConference + } + { + value: { command: "C" } + action: @menu:messageAreaChangeCurrentArea + } + { + value: { command: "L" } + action: @menu:messageAreaMessageList + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: { command: "<" } + action: @systemMethod:prevConf + } + { + value: { command: ">" } + action: @systemMethod:nextConf + } + { + value: { command: "[" } + action: @systemMethod:prevArea + } + { + value: { command: "]" } + action: @systemMethod:nextArea + } + { + value: { command: "D" } + action: @menu:messageAreaSetNewScanDate + } + { + value: { command: "S" } + action: @menu:messageSearch + } + { + value: 1 + action: @menu:messageArea + } + ] + } + + messageSearch: { + desc: Message Search + module: message_base_search + art: MSEARCH + config: { + messageListMenu: messageAreaSearchMessageList + } + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + SM3: { + argName: confTag + } + SM4: { + argName: areaTag + } + ET5: { + argName: toUserName + maxLength: @config:users.usernameMax + } + ET6: { + argName: fromUserName + maxLength: @config:users.usernameMax + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaSearchMessageList: { + desc: Message Search + module: msg_list + art: MSRCHLST + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + TL6: { + // theme me! + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageSearchNoResults: { + desc: Message Search + art: MSRCNORES + config: { + pause: true + } + } + + messageAreaChangeCurrentConference: { + art: CCHANGE + module: msg_conf_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: conf + } + } + submit: { + *: [ + { + value: { conf: null } + action: @method:changeConference + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaSetNewScanDate: { + module: set_newscan_date + desc: Message Base + art: SETMNSDATE + config: { + target: message + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + SM2: { + argName: targetSelection + submit: false + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + changeMessageConfPreArt: { + module: show_art + config: { + method: messageConf + key: confTag + pause: true + cls: true + menuFlags: [ "popParent", "noHistory" ] + } + } + + messageAreaChangeCurrentArea: { + // :TODO: rename this art to ACHANGE + art: CHANGE + module: msg_area_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: area + } + } + submit: { + *: [ + { + value: { area: null } + action: @method:changeArea + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + changeMessageAreaPreArt: { + module: show_art + config: { + method: messageArea + key: areaTag + pause: true + cls: true + menuFlags: [ "popParent", "noHistory" ] + } + } + + messageAreaMessageList: { + module: msg_list + art: MSGLIST + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaViewPost: { + module: msg_area_view_fse + config: { + art: { + header: MSGVHDR + body: MSGBODY + footerView: MSGVFTR + help: MSGVHLP + }, + editorMode: view + editorType: area + } + form: { + 0: { + mci: { + // :TODO: ensure this block isn't even req. for theme to apply... + } + } + 1: { + mci: { + MT1: { + width: 79 + mode: preview + } + } + submit: { + *: [ + { + value: message + action: @method:editModeEscPressed + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + } + 2: { + TLTL: { + mci: { + TL1: { width: 5 } + TL2: { width: 4 } + } + } + } + 4: { + mci: { + HM1: { + // :TODO: (#)Jump/(L)Index (msg list)/Last + items: [ "prev", "next", "reply", "quit", "help" ] + focusItemIndex: 1 + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:prevMessage + } + { + value: { 1: 1 } + action: @method:nextMessage + } + { + value: { 1: 2 } + action: @method:replyMessage + extraArgs: { + menu: messageAreaReplyPost + } + } + { + value: { 1: 3 } + action: @systemMethod:prevMenu + } + { + value: { 1: 4 } + action: @method:viewModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "p", "shift + p" ] + action: @method:prevMessage + } + { + keys: [ "n", "shift + n" ] + action: @method:nextMessage + } + { + keys: [ "r", "shift + r" ] + action: @method:replyMessage + extraArgs: { + menu: messageAreaReplyPost + } + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "?" ] + action: @method:viewModeMenuHelp + } + { + keys: [ "down arrow", "up arrow", "page up", "page down" ] + action: @method:movementKeyPressed + } + ] + } + } + } + + messageAreaReplyPost: { + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + quote: MSGQUOT + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + } + editorMode: edit + editorType: area + } + form: { + 0: { + mci: { + // :TODO: use appropriate system properties for max lengths + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + validate: @systemMethod:validateNonEmpty + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateNonEmpty + } + TL4: { + // :TODO: this is for RE: line (NYI) + //width: 27 + //textOverflow: ... + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + height: 14 + argName: message + mode: edit + } + } + submit: { + *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ], + viewId: 1 + } + ] + } + + 3: { + mci: { + HM1: { + items: [ "save", "discard", "quote", "help" ] + } + } + + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 }, + action: @method:editModeMenuQuote + } + { + value: { 1: 3 } + action: @method:editModeMenuHelp + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "s", "shift + s" ] + action: @method:editModeMenuSave + } + { + keys: [ "d", "shift + d" ] + action: @systemMethod:prevMenu + } + { + keys: [ "q", "shift + q" ] + action: @method:editModeMenuQuote + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + + // Quote builder + 5: { + mci: { + MT1: { + width: 79 + height: 7 + } + VM3: { + width: 79 + height: 4 + argName: quote + } + } + + submit: { + *: [ + { + value: { quote: null } + action: @method:appendQuoteEntry + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:quoteBuilderEscPressed + } + ] + } + } + } + // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu + messageAreaNewPost: { + desc: Posting message, + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + } + editorMode: edit + editorType: area + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: All + validate: @systemMethod:validateNonEmpty + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateNonEmpty + // :TODO: Validate -> close/cancel if empty + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + + 1: { + "mci" : { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + } + 2: { + TLTL: { + mci: { + TL1: { width: 5 } + TL2: { width: 4 } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + "items" : [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + // :TODO: something like the following for overriding keymap + // this should only override specified entries. others will default + /* + "keyMap" : { + "accept" : [ "return" ] + } + */ + } + } + } + } + + + // + // User to User mail aka Email Menu + // + mailMenu: { + art: MAILMNU + desc: Mail Menu + prompt: menuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "C" } + action: @menu:mailMenuCreateMessage + } + { + value: { command: "I" } + action: @menu:mailMenuInbox + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: 1 + action: @menu:mailMenu + } + ] + } + + mailMenuCreateMessage: { + desc: Mailing Someone + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + validate: @systemMethod:validateGeneralMailAddressedTo + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + mailMenuInbox: { + module: msg_list + art: PRVMSGLIST + config: { + menuViewPost: messageAreaViewPost + messageAreaTag: private_mail + } + form: { + 0: { // main list + mci: { + VM1: { + focus: true + submit: true + argName: message + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "delete", "d", "shift + d" ] + action: @method:deleteSelected + } + ] + } + 1: { // delete prompt form + submit: { + *: [ + { + value: { promptValue: 0 } + action: @method:deleteMessageYes + } + { + value: { promptValue: 1 } + action: @method:deleteMessageNo + } + ] + } + } + } + } + + //////////////////////////////////////////////////////////////////////// + // File Base + //////////////////////////////////////////////////////////////////////// + + fileBase: { + desc: File Base + art: FMENU + prompt: fileMenuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { menuOption: "L" } + action: @menu:fileBaseListEntries + } + { + value: { menuOption: "B" } + action: @menu:fileBaseBrowseByAreaSelect + } + { + value: { menuOption: "F" } + action: @menu:fileAreaFilterEditor + } + { + value: { menuOption: "Q" } + action: @systemMethod:prevMenu + } + { + value: { menuOption: "G" } + action: @menu:fullLogoffSequence + } + { + value: { menuOption: "D" } + action: @menu:fileBaseDownloadManager + } + { + value: { menuOption: "W" } + action: @menu:fileBaseWebDownloadManager + } + { + value: { menuOption: "U" } + action: @menu:fileBaseUploadFiles + } + { + value: { menuOption: "S" } + action: @menu:fileBaseSearch + } + { + value: { menuOption: "P" } + action: @menu:fileBaseSetNewScanDate + } + { + value: { menuOption: "E" } + action: @menu:fileBaseExportListFilter + } + ] + } + + fileBaseExportListFilter: { + module: file_base_search + // :TODO: fixme: + art: FSEARCH + config: { + fileBaseListEntriesMenu: fileBaseExportList + } + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + ET3: { + maxLength: 64 + argName: tags + } + SM4: { + maxLength: 64 + argName: areaIndex + } + SM5: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + "filename" + ] + argName: sortByIndex + } + SM6: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseExportList: { + module: file_base_user_list_export + art: FBLISTEXP + config: { + pause: true + templates: { + entry: file_list_entry.asc + } + } + form: { + 0: { + mci: { + TL1: { } + TL2: { } + } + } + } + } + + fileBaseExportListNoResults: { + desc: Browsing Files + art: FBNORES + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileBaseSetNewScanDate: { + module: set_newscan_date + desc: File Base + art: SETFNSDATE + config: { + target: file + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseListEntries: { + module: file_area_list + desc: Browsing Files + config: { + art: { + browse: FBRWSE + details: FDETAIL + detailsGeneral: FDETGEN + detailsNfo: FDETNFO + detailsFileList: FDETLST + help: FBHELP + } + } + form: { + 0: { + mci: { + MT1: { + mode: preview + } + + HM2: { + focus: true + submit: true + argName: navSelect + items: [ + "prev", "next", "details", "toggle queue", "rate", "change filter", "help", "quit" + ] + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFile + } + { + value: { navSelect: 1 } + action: @method:nextFile + } + { + value: { navSelect: 2 } + action: @method:viewDetails + } + { + value: { navSelect: 3 } + action: @method:toggleQueue + } + { + value: { navSelect: 4 } + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + value: { navSelect: 5 } + action: @menu:fileAreaFilterEditor + } + { + value: { navSelect: 6 } + action: @method:displayHelp + } + { + value: { navSelect: 7 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "w", "shift + w" ] + action: @method:showWebDownloadLink + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "t", "shift + t" ] + action: @method:toggleQueue + } + { + keys: [ "f", "shift + f" ] + action: @menu:fileAreaFilterEditor + } + { + keys: [ "v", "shift + v" ] + action: @method:viewDetails + } + { + keys: [ "r", "shift + r" ] + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + keys: [ "?" ] + action: @method:displayHelp + } + ] + } + + 1: { + mci: { + HM1: { + focus: true + submit: true + argName: navSelect + items: [ + "general", "nfo/readme", "file listing" + ] + } + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @method:detailsQuit + } + ] + } + + 2: { + // details - general + mci: {} + } + + 3: { + // details - nfo/readme + mci: { + MT1: { + mode: preview + } + } + } + + 4: { + // details - file listing + mci: { + VM1: { + + } + } + } + } + } + + fileBaseBrowseByAreaSelect: { + desc: Browsing File Areas + module: file_base_area_select + art: FAREASEL + form: { + 0: { + mci: { + VM1: { + focus: true + argName: areaTag + } + } + + submit: { + *: [ + { + value: { areaTag: null } + action: @method:selectArea + } + ] + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseGetRatingForSelectedEntry: { + desc: Rating a File + prompt: fileBaseRateEntryPrompt + config: { + cls: true + } + submit: [ + // :TODO: handle esc/q + { + // pass data back to caller + value: { rating: null } + action: @systemMethod:prevMenu + } + ] + } + + fileBaseListEntriesNoResults: { + desc: Browsing Files + art: FBNORES + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileBaseSearch: { + module: file_base_search + desc: Searching Files + art: FSEARCH + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + ET3: { + maxLength: 64 + argName: tags + } + SM4: { + maxLength: 64 + argName: areaIndex + } + SM5: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + "filename", + ] + argName: sortByIndex + } + SM6: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileAreaFilterEditor: { + desc: File Filter Editor + module: file_area_filter_edit + art: FFILEDT + form: { + 0: { + mci: { + ET1: { + argName: searchTerms + } + ET2: { + maxLength: 64 + argName: tags + } + SM3: { + maxLength: 64 + argName: areaIndex + } + SM4: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + ] + argName: sortByIndex + } + SM5: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + ET6: { + maxLength: 64 + argName: name + validate: @systemMethod:validateNonEmpty + } + HM7: { + focus: true + items: [ + "prev", "next", "make active", "save", "new", "delete" + ] + argName: navSelect + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFilter + } + { + value: { navSelect: 1 } + action: @method:nextFilter + } + { + value: { navSelect: 2 } + action: @method:makeFilterActive + } + { + value: { navSelect: 3 } + action: @method:saveFilter + } + { + value: { navSelect: 4 } + action: @method:newFilter + } + { + value: { navSelect: 5 } + action: @method:deleteFilter + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseDownloadManager: { + desc: Download Manager + module: file_base_download_manager + config: { + art: { + queueManager: FDLMGR + /* + NYI + details: FDLDET + */ + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "download all", "quit" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:downloadAll + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "a", "shift + a" ] + action: @method:downloadAll + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseWebDownloadManager: { + desc: Web D/L Manager + module: file_base_web_download_manager + config: { + art: { + queueManager: FWDLMGR + batchList: BATDLINF + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "get batch link", "quit", "help" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:getBatchLink + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "b", "shift + b" ] + action: @method:getBatchLink + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseDownloadManagerEmptyQueue: { + desc: Empty Download Queue + art: FEMPTYQ + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileTransferProtocolSelection: { + desc: Protocol selection + module: file_transfer_protocol_select + art: FPROSEL + form: { + 0: { + mci: { + VM1: { + focus: true + argName: protocol + } + } + + submit: { + *: [ + { + value: { protocol: null } + action: @method:selectProtocol + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseUploadFiles: { + desc: Uploading + module: upload + config: { + interrupt: never + art: { + options: ULOPTS + fileDetails: ULDETAIL + processing: ULCHECK + dupes: ULDUPES + } + } + + form: { + // options + 0: { + mci: { + SM1: { + argName: areaSelect + focus: true + } + TM2: { + argName: uploadType + items: [ "blind", "supply filename" ] + } + ET3: { + argName: fileName + maxLength: 255 + validate: @method:validateNonBlindFileName + } + HM4: { + argName: navSelect + items: [ "continue", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:optionsNavContinue + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + "actionKeys" : [ + { + "keys" : [ "escape" ], + action: @systemMethod:prevMenu + } + ] + } + + 1: { + mci: { } + } + + // file details entry + 2: { + mci: { + MT1: { + argName: shortDesc + tabSwitchesView: true + focus: true + } + + ET2: { + argName: tags + } + + ME3: { + argName: estYear + maskPattern: "####" + } + + BT4: { + argName: continue + text: continue + submit: true + } + } + + submit: { + *: [ + { + value: { continue: null } + action: @method:fileDetailsContinue + } + ] + } + } + + // dupes + 3: { + mci: { + VM1: { + /* + Use 'dupeInfoFormat' to custom format: + + areaDesc + areaName + areaTag + desc + descLong + fileId + fileName + fileSha256 + storageTag + uploadTimestamp + + */ + + mode: preview + } + } + } + } + } + + fileBaseNoUploadAreasAvail: { + desc: File Base + art: ULNOAREA + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + sendFilesToUser: { + desc: Downloading + module: file_transfer + config: { + // defaults - generally use extraArgs + protocol: zmodem8kSexyz + direction: send + } + } + + recvFilesFromUser: { + desc: Uploading + module: file_transfer + config: { + // defaults - generally use extraArgs + protocol: zmodem8kSexyz + direction: recv + } + } + + + //////////////////////////////////////////////////////////////////////// + // Required entries + //////////////////////////////////////////////////////////////////////// + idleLogoff: { + art: IDLELOG + next: @systemMethod:logoff + } + //////////////////////////////////////////////////////////////////////// + // Demo Section + // :TODO: This entire section needs updated!!! + //////////////////////////////////////////////////////////////////////// + "demoMain" : { + "art" : "demo_selection_vm.ans", + "form" : { + "0" : { + "VM" : { + "mci" : { + "VM1" : { + "items" : [ + "Single Line Text Editing Views", + "Spinner & Toggle Views", + "Mask Edit Views", + "Multi Line Text Editor", + "Vertical Menu Views", + "Horizontal Menu Views", + "Art Display", + "Full Screen Editor" + ], + "height" : 10, + "itemSpacing" : 1, + "justify" : "center", + "focusTextStyle" : "small i" + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@menu:demoEditTextView" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoSpinAndToggleView" + }, + { + "value" : { "1" : 2 }, + "action" : "@menu:demoMaskEditView" + }, + { + "value" : { "1" : 3 }, + "action" : "@menu:demoMultiLineEditTextView" + }, + { + "value" : { "1" : 4 }, + "action" : "@menu:demoVerticalMenuView" + }, + { + "value" : { "1" : 5 }, + "action" : "@menu:demoHorizontalMenuView" + }, + { + "value" : { "1" : 6 }, + "action" : "@menu:demoArtDisplay" + }, + { + "value" : { "1" : 7 }, + "action" : "@menu:demoFullScreenEditor" + } + ] + } + } + } + } + }, + "demoEditTextView" : { + "art" : "demo_edit_text_view1.ans", + "form" : { + "0" : { + "BTETETETET" : { + "mci" : { + "ET1" : { + "width" : 20, + "maxLength" : 20 + }, + "ET2" : { + "width" : 20, + "maxLength" : 40, + "textOverflow" : "..." + }, + "ET3" : { + "width" : 20, + "fillChar" : "-", + "styleSGR1" : "|00|36", + "maxLength" : 20 + }, + "ET4" : { + "width" : 20, + "maxLength" : 20, + "password" : true + }, + "BT5" : { + "width" : 8, + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoSpinAndToggleView" : { + "art" : "demo_spin_and_toggle.ans", + "form" : { + "0" : { + "BTSMSMTM" : { + "mci" : { + "SM1" : { + "items" : [ "Henry Morgan", "François l'Ollonais", "Roche Braziliano", "Black Bart", "Blackbeard" ] + }, + "SM2" : { + "items" : [ "Razor 1911", "DrinkOrDie", "TRSI" ] + }, + "TM3" : { + "items" : [ "Yarly", "Nowaii" ], + "styleSGR1" : "|00|30|01", + "hotKeys" : { "Y" : 0, "N" : 1 } + }, + "BT8" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 8, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 8 + } + ] + } + } + } + }, + "demoMaskEditView" : { + "art" : "demo_mask_edit_text_view1.ans", + "form" : { + "0" : { + "BTMEME" : { + "mci" : { + "ME1" : { + "maskPattern" : "##/##/##", + "styleSGR1" : "|00|30|01", + //"styleSGR2" : "|00|45|01", + "styleSGR3" : "|00|30|35", + "fillChar" : "#" + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoMultiLineEditTextView" : { + "art" : "demo_multi_line_edit_text_view1.ans", + "form" : { + "0" : { + "BTMT" : { + "mci" : { + "MT1" : { + "width" : 70, + "height" : 17, + //"text" : "@art:demo_multi_line_edit_text_view_text.txt", + // "text" : "@systemMethod:textFromFile" + text: "Hints:\n\t* Insert / CTRL-V toggles overtype mode\n\t* CTRL-Y deletes the current line\n\t* Try Page Up / Page Down\n\t* Home goes to the start of line text\n\t* End goes to the end of a line\n\n\nTab handling:\n-------------------------------------------------\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\nA0\tBB\t1\tCCC\t2\tDDD\t3EEEE\nW\t\tX\t\tY\t\tZ\n\nAn excerpt from A Clockwork Orange:\n\"What sloochatted then, of course, was that my cellmates woke up and started joining in, tolchocking a bit wild in the near-dark, and the shoom seemed to wake up the whole tier, so that you could slooshy a lot of creeching and banging about with tin mugs on the wall, as though all the plennies in all the cells thought a big break was about to commence, O my brothers.\n", + "focus" : true + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoHorizontalMenuView" : { + "art" : "demo_horizontal_menu_view1.ans", + "form" : { + "0" : { + "BTHMHM" : { + "mci" : { + "HM1" : { + "items" : [ "One", "Two", "Three" ], + "hotKeys" : { "1" : 0, "2" : 1, "3" : 2 } + }, + "HM2" : { + "items" : [ "Uno", "Dos", "Tres" ], + "hotKeys" : { "U" : 0, "D" : 1, "T" : 2 } + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoVerticalMenuView" : { + "art" : "demo_vertical_menu_view1.ans", + "form" : { + "0" : { + "BTVM" : { + "mci" : { + "VM1" : { + "items" : [ + "|33Oblivion/2", + "|33iNiQUiTY", + "|33ViSiON/X" + ], + "focusItems" : [ + "|33Oblivion|01/|00|332", + "|01|33i|00|33N|01i|00|33QU|01i|00|33TY", + "|33ViSiON/X" + ] + // + // :TODO: how to do the following: + // 1) Supply a view a string for a standard vs focused item + // "items" : [...], "focusItems" : [ ... ] ? + // "draw" : "@method:drawItemX", then items: [...] + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + + }, + "demoArtDisplay" : { + "art" : "demo_selection_vm.ans", + "form" : { + "0" : { + "VM" : { + "mci" : { + "VM1" : { + "items" : [ + "Defaults - DOS ANSI", + "bw_mindgames.ans - DOS", + "test.ans - DOS", + "Defaults - Amiga", + "Pause at Term Height" + ], + // :TODO: justify not working?? + "focusTextStyle" : "small i" + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@menu:demoDefaultsDosAnsi" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoDefaultsDosAnsi_bw_mindgames" + }, + { + "value" : { "1" : 2 }, + "action" : "@menu:demoDefaultsDosAnsi_test" + } + ] + } + } + } + } + }, + "demoDefaultsDosAnsi" : { + "art" : "DM-ENIG2.ANS" + }, + "demoDefaultsDosAnsi_bw_mindgames" : { + "art" : "bw_mindgames.ans" + }, + "demoDefaultsDosAnsi_test" : { + "art" : "test.ans" + }, + "demoFullScreenEditor" : { + "module" : "fse", + "config" : { + "editorType" : "netMail", + "art" : { + "header" : "demo_fse_netmail_header.ans", + "body" : "demo_fse_netmail_body.ans", + "footerEditor" : "demo_fse_netmail_footer_edit.ans", + "footerEditorMenu" : "demo_fse_netmail_footer_edit_menu.ans", + "footerView" : "demo_fse_netmail_footer_view.ans", + "help" : "demo_fse_netmail_help.ans" + } + }, + "form" : { + "0" : { + "ETETET" : { + "mci" : { + "ET1" : { + // :TODO: from/to may be set by args + // :TODO: focus may change dep on view vs edit + "width" : 36, + "focus" : true, + "argName" : "to" + }, + "ET2" : { + "width" : 36, + "argName" : "from" + }, + "ET3" : { + "width" : 65, + "maxLength" : 72, + "submit" : [ "enter" ], + "argName" : "subject" + } + }, + "submit" : { + "3" : [ + { + "value" : { "subject" : null }, + "action" : "@method:headerSubmit" + } + ] + } + } + }, + "1" : { + "MT" : { + "mci" : { + "MT1" : { + "width" : 79, + "height" : 17, + "text" : "", // :TODO: should not be req. + "argName" : "message" + } + }, + "submit" : { + "*" : [ + { + "value" : "message", + "action" : "@method:editModeEscPressed" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 1 + } + ] + } + }, + "2" : { + "TLTL" : { + "mci" : { + "TL1" : { + "width" : 5 + }, + "TL2" : { + "width" : 4 + } + } + } + }, + "3" : { + "HM" : { + "mci" : { + "HM1" : { + // :TODO: Continue, Save, Discard, Clear, Quote, Help + "items" : [ "Save", "Discard", "Quote", "Help" ] + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@method:editModeMenuSave" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoMain" + }, + { + "value" : { "1" : 2 }, + "action" : "@method:editModeMenuQuote" + }, + { + "value" : { "1" : 3 }, + "action" : "@method:editModeMenuHelp" + }, + { + "value" : 1, + "action" : "@method:editModeEscPressed" + } + ] + }, + "actionKeys" : [ // :TODO: Need better name + { + "keys" : [ "escape" ], + "action" : "@method:editModeEscPressed" + } + ] + } + } + } + } + } } diff --git a/util/to_ansi.js b/util/to_ansi.js new file mode 100755 index 00000000..72838493 --- /dev/null +++ b/util/to_ansi.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const { controlCodesToAnsi } = require('../core/color_codes.js'); + +const fs = require('graceful-fs'); +const iconv = require('iconv-lite'); + +const ToolVersion = '1.0.0'; + +function main() { + const argv = exports.argv = require('minimist')(process.argv.slice(2), { + alias : { + h : 'help', + v : 'version', + } + }); + + if(argv.version) { + console.info(ToolVersion); + return 0; + } + + if(0 === argv._.length || argv.help) { + console.info('usage: to_ansi.js [--version] [--help] PATH'); + return 0; + } + + const path = argv._[0]; + + fs.readFile(path, (err, data) => { + if(err) { + console.error(err.message); + return -1; + } + + data = iconv.decode(data, 'cp437'); + console.info(controlCodesToAnsi(data)); + return 0; + }); +} + +main();