Skip to content
Snippets Groups Projects
Commit 0878b175 authored by echicken's avatar echicken :chicken:
Browse files

Bullshit v4 (source & tooling).

This is a rewrite of the Bullshit bulletin lister. Should have all
the same features as v3, but much faster at lightbar navigation
and especially better at scrolling large files. (frame.js has been
replaced here by my new screen management library, 'swindows'.)

Sysops don't need to concern themselves with the files in this
commit; the build generated from these has been placed in the same
location as the old 'bullshit.js' script. Just copy that one to
wherever you normally run this from and it'll take over. Or you
could run Bullshit from the repo if you like to live dangerously.
parent 16e3b07f
No related branches found
No related tags found
1 merge request!455Update branch with changes from master
{
"extends": "./node_modules/@swag/ts4s/.babelrc.json"
}
\ No newline at end of file
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "echo source /usr/share/bash-completion/completions/git >> /home/node/.bashrc"
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
.env
.tmp
node_modules
\ No newline at end of file
# bullshit
Lightbar bulletin lister/reader for Synchronet BBS 3.16+. Post your bulletins to a message base to add them to the list, or load bulletins from text or ANSI files.
## Setup
### Create a message area
This step is optional. You can skip this part if you don't want to pull bulletins out of a message base.
Launch SCFG (BBS->Configure in the Synchronet Control Panel on Windows.)
In 'Message Areas', select your local message group, and create a new sub with the following details:
```
Long Name Bulletins
Short Name Bulletins
QWK Name BULLSHIT
Internal Code BULLSHIT
Access Requirements LEVEL 90
Reading Requirements LEVEL 90
Posting Requirements LEVEL 90
```
Toggle Options...
```
Default on for new scan No
Forced on for new scan No
Default on for your scan No
```
_Name this message area whatever you want, and you can use an existing message area if you wish. Ideally only the sysop can read or post to this area, and it won't be included in the new message scan._
### Create an external program entry
SCFG, return to the main menu, select 'External Programs', then 'Online Programs (Doors)', choose the section you wish to add Bullshit to, then create a new entry with the following details:
```
Name Bullshit
Internal Code BULLSHIT
Start-up Directory /sbbs/xtrn/bullshit
Command Line ?build/bullshit.js
Multiple Concurrent Users Yes
```
If you want Bullshit to run during your logon process, set the following:
```
Execute on Event logon
```
All other options can be left at their default settings.
## Customization
_If you were running a previous version of Bullshit where settings were stored at `xtrn/bullshit/bullshit.ini` you can skip this step; your settings will be migrated automatically._
Create `ctrl/modopts.d/bullshit.ini`. Here's an example. Change `messageBase` to match the internal code of your message base, or omit it if you don't want to use one. You can leave out the `[bullshit:files]` section if you don't have any files to include.
```ini
[bullshit]
messageBase = BULLSHIT
maxMessages = 100
newOnly = false
[bullshit:colors]
title = WHITE
text = LIGHTGRAY
lightBar = BG_CYAN|LIGHTCYAN
border = WHITE,LIGHTCYAN,CYAN,LIGHTBLUE
[bullshit:files]
LORD Scores = /path/to/lordscor.ans
```
## Bullshit for the web
The included '999-bullshit.xjs' file is compatible with webv4. You can add it to your site by copying it to the `webv4/mods/pages` subdirectory, renaming it as needed. You could also just copy the contents of this file into another of your pages if you wish for bulletins to show up there (000-home.xjs, for example.)
_I haven't actually tested the latest version of this XJS file, so, uh ... yeah._
## Support
### DOVE-Net
Post a message to `echicken` in the Synchronet Sysops area.
### IRC
Find me in `#synchronet` on `irc.synchro.net`.
\ No newline at end of file
This diff is collapsed.
{
"name": "bullshit",
"version": "4.0.0",
"description": "",
"main": "src/bullshit.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "ts4s build -s src -b ./",
"deploy": "npm run build && eval $(cat .env) && scp -P $SSH_PORT ./*.*js $SSH_USER@$SSH_HOST:$DEPLOY_PATH"
},
"author": "echicken",
"license": "MIT",
"dependencies": {
"@swag/ts4s": "git+ssh://git@gitlab.synchro.net:swag/ts4s.git#newload",
"swindows": "git+ssh://git@gitlab.synchro.net:echicken/swindows.git"
}
}
import type { ICgaDefs, INodeDefs, ISbbsDefs } from '@swag/ts4s';
import { load } from '@swag/ts4s';
import * as swindows from 'swindows';
import { getOptions, migrate } from './lib/options';
import UI from './lib/UI';
const cgadefs: ICgaDefs = load('cga_defs.js');
const nodedefs: INodeDefs = load('nodedefs.js');
const sbbsdefs: ISbbsDefs = load('sbbsdefs.js');
const { bbs, console } = js.global;
function init() {
js.on_exit(`console.attributes = ${console.attributes}`);
js.on_exit(`bbs.sys_status = ${bbs.sys_status}`);
js.on_exit('console.home();');
js.on_exit('console.write("\x1B[0;37;40m")');
js.on_exit('console.write("\x1B[2J")');
js.on_exit('console.write("\x1B[?25h");');
js.time_limit = 0;
bbs.sys_status |= sbbsdefs.SS_MOFF;
console.clear(cgadefs.BG_BLACK|cgadefs.LIGHTGRAY);
}
function main(): void {
migrate();
const windowManager = new swindows.WindowManager();
const options = getOptions();
const ui = new UI(windowManager, options);
if ((bbs.node_action&nodedefs.NODE_LOGN) === nodedefs.NODE_LOGN && ui.noNewItems) return;
windowManager.hideCursor();
while (!js.terminated) {
windowManager.refresh();
const input = console.getkey().toLowerCase();
if (!ui.getCmd(input)) break;
}
}
init();
main();
import type { IKeyDefs } from '@swag/ts4s';
import type { IOptions } from './options';
import { load } from '@swag/ts4s';
import * as swindows from 'swindows';
import { getWindowOptions } from './options';
import { getList } from './list';
const keydefs: IKeyDefs = load('key_defs.js');
export default class UI {
options: IOptions;
windowManager: swindows.WindowManager;
listWindow: swindows.ControlledWindow;
listMenu: swindows.LightBar;
itemWindow: swindows.ControlledWindow | undefined;
activeWindow: 'list' | 'item' = 'list';
noNewItems: boolean = false;
constructor(windowManager: swindows.WindowManager, options: IOptions) {
this.options = options;
this.windowManager = windowManager;
const listWindowOptions = getWindowOptions(windowManager, options.colors.border, options.bullshit.title, 'Q to quit');
this.listWindow = new swindows.ControlledWindow(listWindowOptions);
const items = getList(options, windowManager, this.itemHandler.bind(this));
if (items.length < 1) {
items.push({ text: 'No new bulletins', onSelect: () => {} });
this.noNewItems = true;
}
this.listMenu = new swindows.LightBar({
items,
window: this.listWindow,
colors: {
normal: options.colors.list,
highlight: options.colors.lightBar
}
});
this.listMenu.draw();
}
itemHandler(title: string, body: string, wrap: boolean): void {
const itemWindowOptions = getWindowOptions(this.windowManager, this.options.colors.border, title, 'Q to quit');
itemWindowOptions.attr = this.options.colors.text;
this.itemWindow = new swindows.ControlledWindow(itemWindowOptions);
if (wrap) {
this.itemWindow.wrap = swindows.defs.WRAP.WORD;
this.itemWindow.write(js.global.lfexpand(body));
} else {
this.itemWindow.wrap = swindows.defs.WRAP.NONE;
this.itemWindow.contentWindow.writeAnsi(body, 80);
}
this.itemWindow.scrollTo({ x: 0, y: 0 });
this.activeWindow = 'item';
}
getCmd(cmd: string): boolean {
if (this.activeWindow === 'list') {
if (cmd === 'q') return false;
this.listMenu.getCmd(cmd);
} else if (this.itemWindow !== undefined) {
switch (cmd) {
case 'q':
this.itemWindow?.close();
this.activeWindow = 'list';
break;
case keydefs.KEY_UP:
this.itemWindow.scroll(0, -1);
break;
case keydefs.KEY_PAGEUP:
this.itemWindow.scroll(0, -this.itemWindow.contentWindow.size.height);
break;
case keydefs.KEY_HOME:
this.itemWindow.scrollTo({ x: 0, y: 0 });
break;
case keydefs.KEY_DOWN:
this.itemWindow.scroll(0, 1);
break;
case keydefs.KEY_PAGEDN:
this.itemWindow.scroll(0, this.itemWindow.contentWindow.size.height);
break;
case keydefs.KEY_END:
this.itemWindow.scrollTo({ x: 0, y: this.itemWindow.contentWindow.dataHeight - this.itemWindow.contentWindow.size.height });
break;
default:
break;
}
}
return true;
}
}
\ No newline at end of file
import type { INodeDefs, ISmbDefs } from '@swag/ts4s';
import type { IOptions } from './options';
import { load } from '@swag/ts4s';
import { WindowManager } from 'swindows';
import { ILightBarItem } from 'swindows/src/LightBar';
const nodedefs: INodeDefs = load('nodedefs.js');
const smbdefs: ISmbDefs = load('smbdefs.js');
const { file_exists, file_date, format, strftime, time, bbs, system, user, MsgBase, File } = js.global;
const historyFile = format(`${system.data_dir}user/%04d.bullshit`, user.number);
type handler = (title: string, body: string, wrap: boolean) => void;
interface ISortable {
item: ILightBarItem,
date: number,
}
interface IUserHistory {
files: Record<string, number>,
messages: number[],
}
function formatDate(date: number): string {
return strftime('%b %d %Y %H:%M', date);
}
function formatItem(text: string, date: number, width: number): string {
return format(`%-${width - 17}s%s`, text.substring(0, width - 18), formatDate(date));
}
function getUserHistory(): IUserHistory {
const ret = { files: {}, messages: [] };
if (!file_exists(historyFile)) return ret;
const f = new File(historyFile);
if (!f.open('r')) return ret;
const history = JSON.parse(f.read()) as IUserHistory;
f.close();
return history;
}
function setUserHistory(history: IUserHistory): void {
const f = new File(historyFile);
if (!f.open('w')) return;
f.write(JSON.stringify(history));
f.close();
}
export function getList(options: IOptions, windowManager: WindowManager, itemHandler: handler): ILightBarItem[] {
const width = windowManager.size.width - 3
const sortables: ISortable[] = [];
const history: IUserHistory = getUserHistory();
for (const file in options.files) {
if (!file_exists(options.files[file])) continue;
if ((bbs.node_action&nodedefs.NODE_LOGN) && options.bullshit.newOnly && history.files[file] !== undefined && file_date(options.files[file]) <= history.files[file]) continue;
const title = file;
const date = file_date(options.files[file]);
const sortable: ISortable = {
item: {
text: formatItem(title, date, width),
onSelect: () => {
const f = new File(options.files[file]);
if (!f.open('rb')) return;
const body = f.read();
f.close();
itemHandler(`${title} (${formatDate(date)})`, body, false);
const history = getUserHistory();
history.files[file] = time();
setUserHistory(history);
}
},
date: file_date(options.files[file]),
};
sortables.push(sortable);
}
const msgBase = new MsgBase(options.bullshit.messageBase);
if (msgBase.open()) {
let messages: ISortable[] = [];
for (let n = msgBase.first_msg; n <= msgBase.last_msg; n++) {
const h = msgBase.get_msg_header(false, n);
if (h === null || (h.attr&smbdefs.MSG_DELETE) > 0) continue;
if ((bbs.node_action&nodedefs.NODE_LOGN) && options.bullshit.newOnly && history.messages.includes(n)) continue;
const sortable: ISortable = {
item: {
text: formatItem(h.subject, h.when_written_time, width),
onSelect: () => {
if (!msgBase.open()) return;
const body = msgBase.get_msg_body(false, n);
if (body === null) return;
itemHandler(`${h.subject} (${formatDate(h.when_written_time)})`, body, true);
const history = getUserHistory();
if (!history.messages.includes(n)) {
history.messages.push(n);
setUserHistory(history);
}
}
},
date: h.when_written_time,
};
messages.push(sortable);
}
msgBase.close();
if (messages.length > options.bullshit.maxMessages) messages = messages.splice(-options.bullshit.maxMessages);
sortables.push(...messages);
}
return sortables.sort((a, b) => b.date - a.date).map(e => e.item);
}
import type { ICgaDefs } from '@swag/ts4s';
import { load } from '@swag/ts4s';
import * as swindows from 'swindows';
const cgadefs: ICgaDefs = load('cga_defs.js');
const { file_exists, file_rename, system, File } = js.global;
function parseColorOption(color: string): swindows.types.attr {
const c = color.split('|');
let ret = cgadefs[c[0] as keyof typeof cgadefs] as swindows.types.attr;
if (c.length > 1) ret |= (cgadefs[c[1] as keyof typeof cgadefs] as swindows.types.attr);
if ((ret & (7 << 4)) === 0) ret |= cgadefs.BG_BLACK;
return ret as swindows.types.attr;
}
export interface IOptions {
bullshit: {
messageBase: string,
maxMessages: number,
newOnly: boolean,
title: string
},
colors: {
title: swindows.types.attr,
text: swindows.types.attr,
heading: swindows.types.attr,
lightBar: swindows.types.attr,
list: swindows.types.attr,
border: swindows.types.attr[],
},
files: Record<string, string>,
}
export function migrate(): void {
const oldFile = `${js.exec_dir}bullshit.ini`;
if (!file_exists(oldFile)) return;
let f = new File(oldFile);
if (!f.open('r')) return;
const oldRoot = f.iniGetObject();
const oldColors = f.iniGetObject('colors');
const oldFiles = f.iniGetObject('files');
f.close();
const opts = {
bullshit: {
messageBase: oldRoot?.messageBase ?? '',
maxMessages: oldRoot?.maxMessages ?? 100,
newOnly: oldRoot?.newOnly === undefined ? false : true,
title: 'Bulletins',
},
colors: {
title: (oldColors?.title as string) ?? 'WHITE',
text: (oldColors?.text as string) ?? 'LIGHTGRAY',
lightBar: `${(oldColors?.lightbarForeground as string) ?? 'WHITE'}|${(oldColors?.lightbarBackground as string) ?? 'BG_CYAN'}`,
border: oldColors?.border ?? 'WHITE,LIGHTCYAN,CYAN,LIGHTBLUE',
},
files: oldFiles ?? {},
};
f = new File(`${system.ctrl_dir}modopts.d/bullshit.ini`);
if (!f.open(f.exists ? 'r+' : 'w+')) return;
f.iniSetObject('bullshit', opts.bullshit);
f.iniSetObject('bullshit:colors', opts.colors);
f.iniSetObject('bullshit:files', opts.files);
f.close();
file_rename(oldFile, `${oldFile}.old`);
}
export function getOptions(): IOptions {
const ret: IOptions = {
bullshit: {
messageBase: '',
maxMessages: 100,
newOnly: false,
title: 'Bulletins',
},
colors: {
title: (cgadefs.BG_BLACK|cgadefs.WHITE) as swindows.types.attr,
text: (cgadefs.BG_BLACK|cgadefs.LIGHTGRAY) as swindows.types.attr,
heading: (cgadefs.BG_BLACK|cgadefs.DARKGRAY) as swindows.types.attr,
lightBar: (cgadefs.BG_CYAN|cgadefs.WHITE) as swindows.types.attr,
list: (cgadefs.BG_BLACK|cgadefs.LIGHTGRAY) as swindows.types.attr,
border: [
((cgadefs.BG_BLACK|cgadefs.WHITE) as swindows.types.attr),
((cgadefs.BG_BLACK|cgadefs.LIGHTCYAN) as swindows.types.attr),
((cgadefs.BG_BLACK|cgadefs.CYAN) as swindows.types.attr),
((cgadefs.BG_BLACK|cgadefs.LIGHTBLUE) as swindows.types.attr),
]
},
files: {},
};
const f = new File(`${system.ctrl_dir}modopts.d/bullshit.ini`);
if (!f.open('r')) throw new Error(`Failed to open ${f.name} for reading`);
const bullshitIni = f.iniGetObject('bullshit');
const colorsIni = f.iniGetObject('bullshit:colors');
const filesIni = f.iniGetObject('bullshit:files');
f.close();
if (typeof bullshitIni?.messageBase === 'string') ret.bullshit.messageBase = bullshitIni.messageBase;
if (typeof bullshitIni?.maxMessages === 'number') ret.bullshit.maxMessages = bullshitIni.maxMessages;
if (ret.bullshit.maxMessages < 1) ret.bullshit.maxMessages = Infinity;
if (typeof bullshitIni?.newOnly === 'boolean') ret.bullshit.newOnly = bullshitIni.newOnly;
if (typeof bullshitIni?.title === 'string') ret.bullshit.title = bullshitIni.title;
if (typeof colorsIni?.title === 'string') ret.colors.title = parseColorOption(colorsIni.title);
if (typeof colorsIni?.text === 'string') ret.colors.title = parseColorOption(colorsIni.text);
if (typeof colorsIni?.heading === 'string') ret.colors.title = parseColorOption(colorsIni.heading);
if (typeof colorsIni?.lightBar === 'string') ret.colors.title = parseColorOption(colorsIni.lightBar);
if (typeof colorsIni?.list === 'string') ret.colors.title = parseColorOption(colorsIni.list);
if (typeof colorsIni?.border === 'string' && colorsIni.border.length > 0) {
ret.colors.border = [];
const border = colorsIni.border.split(',');
for (const b of border) {
ret.colors.border.push(parseColorOption(b));
}
}
for (const file in filesIni) {
ret.files[file] = filesIni[file] as string;
}
return ret;
}
export function getWindowOptions(windowManager: swindows.WindowManager, border: swindows.types.attr[], title?: string, footer?: string): swindows.types.IControlledWindowOptions {
return {
border: {
style: swindows.defs.BORDER_STYLE.SINGLE,
pattern: swindows.defs.BORDER_PATTERN.DIAGONAL,
attr: border,
},
title: {
text: title ?? '',
attr: ((cgadefs.BG_BLACK|cgadefs.WHITE) as swindows.types.attr),
},
footer: {
text: footer ?? '',
attr: ((cgadefs.BG_BLACK|cgadefs.WHITE) as swindows.types.attr),
alignment: swindows.defs.ALIGNMENT.RIGHT,
},
position: { x: 0, y: 0 },
size: {
width: windowManager.size.width,
height: windowManager.size.height
},
scrollBar: {
vertical: {
enabled: true,
}
},
windowManager,
name: '',
}
};
{
"extends": "./node_modules/@swag/ts4s/tsconfig-synchronet.json",
"compilerOptions": {
"rootDir": ".",
"outDir": ".",
"target": "ESNext",
"lib": ["ESNext"]
},
"include": [
"src/bullshit.ts",
],
"exclude": [
"node_modules"
]
}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment