Compare commits

...

20 Commits

Author SHA1 Message Date
ef3ef0950f Fix manpage install location 2024-10-11 17:38:07 -04:00
78bdf578bf Manpage fixes 2024-10-11 17:29:50 -04:00
cd3f296fc5 More compatibility fixes 2024-10-11 17:16:33 -04:00
ea527a2065 Don't use for each loops
The hare compiler on OpenBSD doesn't support them :(
2024-10-11 17:08:16 -04:00
d921a2b144 Escape LF in board file, refuse to store or print control chars 2024-10-11 16:54:56 -04:00
31d7c63114 Add Makefile 2024-10-11 16:26:59 -04:00
e575f1b3d3 Add .gitignore 2024-10-11 16:26:52 -04:00
e55136e433 More man page tweaks 2024-10-11 16:26:30 -04:00
9b31e4081d Improve man page 2024-10-11 16:23:55 -04:00
017983d672 Mark the bulb module as internal 2024-10-11 16:23:37 -04:00
da0032b531 Add man page 2024-10-11 15:45:38 -04:00
8021dfce8a Update usage information 2024-10-11 14:34:10 -04:00
e15a63ddb0 Print out better information when an invalid number is given 2024-10-11 14:29:28 -04:00
dddb8648f4 Program no longer has subcommands 2024-10-11 14:18:16 -04:00
ec7cfb4448 Improve help text 2024-10-11 11:20:26 -04:00
6b81c63251 Update readme 2024-10-11 11:19:16 -04:00
8f5d7d6df2 Posting now reads from stdin, supports multiline input 2024-10-11 11:17:52 -04:00
1ca8a9ce98 Cast runes to u32 instead of int for comparison 2024-10-09 22:36:43 -04:00
61f85cfdda Fixed seek method of the read command 2024-10-09 22:27:46 -04:00
a2543ef78e Remove pointless seek 2024-10-09 21:45:55 -04:00
7 changed files with 387 additions and 200 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/bulb

31
Makefile Normal file
View File

@@ -0,0 +1,31 @@
.POSIX:
.SUFFIXES:
HARE=hare
HAREFLAGS=
DESTDIR=
PREFIX=/usr/local
BINDIR=$(PREFIX)/bin
MANDIR=$(PREFIX)/man
all: bulb
bulb:
$(HARE) build $(HAREFLAGS) -o $@ cmd/$@/
check:
$(HARE) test $(HAREFLAGS)
clean:
rm -f bulb
install:
mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
install -Dm755 bulb $(DESTDIR)$(BINDIR)/bulb
install -Dm755 doc/bulb.1 $(DESTDIR)$(MANDIR)/man1
uninstall:
rm -f $(DESTDIR)$(BINDIR)/bulb
rm -f $(DESTDIR)$(MANDIR)/man1/bulb.1
.PHONY: all check clean install uninstall

View File

@@ -22,27 +22,15 @@ re-writing chat history.
## Usage
To list available boards:
```
bulb
```
bulb: bulletin board
To post a message:
Usage: bulb [-hlpu] [-b <board>] [-n <number>]
-h: print this help text
-b <board>: specify a board other than general
-l: list available boards and exit
-n <number>: display N most recent messages
-p: post a message (from stdin)
-u: operate undercover (anonymously)
```
bulb post Message text
bulb post -b <board-name> Message text
```
To read recent messages:
```
bulb read
bulb read -b <board-name>
bulb read -n <message-count>
```
Note that when not specified, the board name defaults to `general`. For this
reason, it is recommended to ensure that a board with this name exists.

View File

@@ -1,119 +0,0 @@
use fs;
use fmt;
use io;
use path;
use os;
use strings;
let bulb_path: []str = [];
@init fn bulb_path() void = bulb_path = strings::split(os::tryenv("BULBPATH", "/var/bulb"), ":");
@fini fn bulb_path() void = free(bulb_path);
export type no_such_board = !void;
export type invalid_board = !void;
export type error = (io::error | fs::error | no_such_board | invalid_board);
export fn strerror(err: error) str = match(err) {
case no_such_board => return "No such board";
case invalid_board => return "Invalid board name";
case let err: fs::error => return fs::strerror(err);
case let err: io::error => return io::strerror(err);
};
export fn read(board: str, number: int) (void | error) = {
validate_board_name(board)?;
let location = look_up_board(board)?;
let file = os::open(location)?;
defer io::close(file)!;
seek_to_nth_last_line(file, number)?;
io::copy(os::stdout, file)?;
};
export fn post(board: str, user_name: (str | void), message: str) (void | error) = {
validate_board_name(board)?;
let location = look_up_board(board)?;
let file = os::open(location, fs::flag::APPEND | fs::flag::WRONLY)?;
defer io::close(file)!;
let user = match(user_name) {
case let user: str => yield(user);
case void => yield "anonymous";
};
fmt::fprintf(file, "{}: {}\n", user, message)?;
};
export fn list() (void | error) = {
for (let directory .. bulb_path) list_boards_in(directory)?;
};
export fn look_up_board(board: str) (str | no_such_board | invalid_board) = {
validate_board_name(board)?;
static let buf = path::buffer { ... };
for (let directory .. bulb_path) {
let location = path::set(&buf, directory, board)!;
match (os::stat(location)) {
case fs::filestat => return location;
case => void;
};
};
return no_such_board;
};
export fn validate_board_name(board: str) (void | invalid_board) = {
let iter = strings::iter(board);
for (true) match (strings::next(&iter)) {
case let run: rune =>
if (
run: int == '/' ||
run: int == '.' ||
run: int == '\\' ||
run: int == ':') {
return invalid_board;
};
case done => return void;
};
};
fn list_boards_in(directory: str) (void | fs::error) = {
let entries = os::readdir(directory)?;
defer fs::dirents_free(entries);
for (let entry .. entries) {
fmt::println(entry.name)!;
};
};
fn seek_to_nth_last_line(file: io::handle, number: int) (void | io::error) = {
let buffer: [256]u8 = [0...];
let end = io::seek(file, 0, io::whence::END)?;
let step = len(buffer): io::off;
let current = end;
for (true) {
// read a chunk in reverse, or stop if there is nothing left
let buffer = buffer[..];
if (current - step > 0) {
current -= step;
} else {
buffer = buffer[..current];
current = 0;
};
io::seek(file, current, io::whence::SET)?;
let length = match (io::read(file, buffer)?) {
case let length: size => yield length;
case io::EOF => break;
};
io::seek(file, current, io::whence::SET)?;
// look for line breaks in that chunk
let buffer = buffer[..length];
for (let index = len(buffer): int - 1; index >= 0; index -= 1) {
if (buffer[index: size] == '\n') number -= 1;
if (number <= 0) {
io::seek(file, current + index: io::off, io::whence::SET)?;
return void;
};
};
};
return void;
};

View File

@@ -1,8 +1,8 @@
use bulb;
use errors;
use fmt;
use fs;
use getopt;
use internal::bulb;
use io;
use os;
use path;
@@ -19,83 +19,68 @@ export fn main() void = {
const cmd = getopt::parse(
os::args,
"bulletin board",
("post", [
('a', "post anonymously"),
('b', "board", "which board to post on"),
"message...",
]): getopt::subcmd_help,
("read", [
('b', "board", "which board to read"),
('n', "number", "display N most recent messages"),
]): getopt::subcmd_help);
('b', "board", "specify a board other than general"),
('l', "list available boards and exit"),
('n', "number", "display N most recent messages"),
('p', "post a message (from stdin)"),
('u', "operate undercover (anonymously)"));
defer getopt::finish(&cmd);
match (cmd.subcmd) {
case let subcmd: (str, *getopt::command) =>
if (subcmd.0 == "post") {
subcmd_post(subcmd.1);
} else if (subcmd.0 == "read") {
match (subcmd_read(subcmd.1)) {
case errors::invalid =>
getopt::printhelp(os::stdout, name, cmd.help)!;
os::exit(os::status::FAILURE);
case void => void;
};
};
case void =>
subcmd_ls(&cmd);
};
};
fn subcmd_post(cmd: *getopt::command) void = {
let anonymous = false;
let board = default_board;
for (let opt .. cmd.opts) {
switch (opt.0) {
case 'a' => anonymous = true;
case 'b' => board = opt.1;
case => abort();
};
};
let user_name = if (anonymous) void else get_user_name();
let message = strings::join(" ", cmd.args...);
defer free(message);
let list = false;
let number = 8;
let post = false;
let anonymous = false;
match (bulb::post(board, user_name, message)) {
case let err: bulb::error =>
fmt::errorf("{}: Could not read {}: {}\n", name, board, bulb::strerror(err))!;
case void => void;
};
};
fn subcmd_read(cmd: *getopt::command) (void | errors::invalid) = {
let board = default_board;
let number = 8;
for (let opt .. cmd.opts) {
for (let index = 0z; index < len(cmd.opts); index += 1) {
let opt = cmd.opts[index];
switch (opt.0) {
case 'b' =>
board = opt.1;
case 'l' =>
list = true;
case 'n' =>
match (strconv::stoi(opt.1)) {
case let num: int => number = num;
case => return errors::invalid;
case let num: int =>
number = num;
case =>
fmt::errorf("{}: option -{} requires a number\n", name, opt.0)!;
getopt::printusage(os::stdout, name, cmd.help)!;
os::exit(os::status::FAILURE);
};
case 'p' =>
post = true;
case 'u' =>
anonymous = true;
case => abort();
};
};
match (bulb::read(board, number)) {
case let err: bulb::error =>
fmt::errorf("{}: Could not read {}: {}\n", name, board, bulb::strerror(err))!;
case void => void;
if (list) {
// list boards and exit
match (bulb::list(os::stdout)) {
case let err: bulb::error =>
fmt::errorf("{}: could not list boards: {}\n", name, bulb::strerror(err))!;
os::exit(os::status::FAILURE);
case void =>
os::exit(os::status::SUCCESS);
};
};
};
fn subcmd_ls(cmd: *getopt::command) void = {
match (bulb::list()) {
if (post) {
// post a message (from stdin)
let user_name = if (anonymous) void else get_user_name();
match (bulb::post(os::stdin, board, user_name)) {
case let err: bulb::error =>
fmt::errorf("{}: could not post to {}: {}\n", name, board, bulb::strerror(err))!;
case void => void;
};
};
// read latest posts on the board
match (bulb::read(os::stdout, board, number)) {
case let err: bulb::error =>
fmt::errorf("{}: Could not list boards: {}\n", name, bulb::strerror(err))!;
fmt::errorf("{}: could not read {}: {}\n", name, board, bulb::strerror(err))!;
case void => void;
};
};

102
doc/bulb.1 Normal file
View File

@@ -0,0 +1,102 @@
.\" Copyright (c) 2024 Sasha Koshka <sashakoshka@tebibyte.media>
.\"
.\" This work is licensed under CC BY-SA 4.0. To see a copy of this license,
.\" visit <http://creativecommons.org/licenses/by-sa/4.0/>.
.\"
.TH BULB 1 2024-10-11
.SH NAME
bulb \(en bulletin board
.\"
.SH SYNOPSIS
bulb
.RB [ -hlpu ]
.RB [ -b\ board ]
.RB [ -n\ number ]
.\"
.SH DESCRIPTION
Read from and post to boards (text files) on the system. Posts are plain text,
multiline, and each one is tagged with the user name of its author.
.\"
.SH OPTIONS
.IP \fB-h\fP
Prints the help text and exits.
.IP \fB-b\fP\ \fIboard\fP
Specifies a board other than \(lqgeneral\(rq.
.IP \fB-l\fP
Lists available boards and exits.
.IP \fB-n\fP\ \fInumber\fP
Takes a numeric argument as the amount of recent posts to print out, the default
being 8.
.IP \fB-p\fP
Posts a message read from the standard input. See the STANDARD INPUT section.
.IP \fB-u\fP
Operates undercover. All posts made will be under the name \(lqanonymous\(rq.
.\"
.SH ENVIRONMENT
The BULBPATH environment variable specifies a colon-separated list of
directories (much like PATH) in which to search for boards. Directories at the
start take precedence over directories at the end. It has a value of
.I /var/bulb
by default.
.SH FILES
.I /var/bulb/*
.\"
.SH STANDARD INPUT
When the
.B -p
option is supplied, the standard input will be read until EOF. The resulting
text will be posted under the name of the current user in whichever board is
selected.
.\"
.SH STANDARD OUTPUT
If the
.B -l
option is supplied, a list of available boards will be written to the standard
output. If not, the last
.B -n
messages in the selected board will be written.
.\"
.SH DIAGNOSTICS
In the event of an I/O error, a debug message will be printed and the program
will exit with an error code. In the event of a bad invocation, a debug message
will be printed alongside usage text and the program will exit with an error
code.
.\"
.SH RATIONALE
The commands
.BR talk (1p),
and
.BR write (1p),
and
.BR wall (1p)
can be used effectively for textual intra-system communication, but they require
that the receiving user(s) be both logged in and monitoring their terminal for
any messages to ever get through. The
.BR bulb (1)
command was written to support asynchronous, persistent conversations between
users of the same system.
.\"
.SH AUTHOR
Written by Sasha Koshka
.MT sashakoshka@tebibyte.media
.ME .
.\"
.SH COPYRIGHT
Copyright \(co 2024 Sasha Koshka (pseudonymous). License GPLv3+: GNU GPL version
3 or later <https://gnu.org/licenses/gpl.html>
\."
.SH SEE ALSO
.BR talk (1p),
.BR write (1p),
.BR wall (1p)

199
internal/bulb/bulb.ha Normal file
View File

@@ -0,0 +1,199 @@
use ascii;
use bufio;
use fs;
use fmt;
use io;
use os;
use path;
use strings;
use types;
// TODO
// two issues:
// A: posts are stored exactly as they are formatted
// B: posts need to be sanitized from control chars going in and going out
// C: specifying a number of posts to read only works with lines because it
// cant separate out the individual posts
//
// all of these can be fixed by escaping line breaks as the post is written to
// the board, and then rendering them once they get to the reading stage.
// sanitize the text at both stages.
let bulb_path: []str = [];
@init fn bulb_path() void = bulb_path = strings::split(os::tryenv("BULBPATH", "/var/bulb"), ":");
@fini fn bulb_path() void = free(bulb_path);
export type no_such_board = !void;
export type invalid_board = !void;
export type error = !(io::error | fs::error | no_such_board | invalid_board);
export fn strerror(err: error) str = match(err) {
case no_such_board => return "No such board";
case invalid_board => return "Invalid board name";
case let err: fs::error => return fs::strerror(err);
case let err: io::error => return io::strerror(err);
};
export fn read(output: io::handle, board: str, number: int) (void | error) = {
validate_board_name(board)?;
let location = look_up_board(board)?;
let file = os::open(location)?;
defer io::close(file)!;
// we add 1 to account for the blank line that will always be at the
// bottom
seek_to_nth_last_line(file, number + 1)?;
static let output_wbuf: [os::BUFSZ]u8 = [0...];
let output = bufio::init(output, [], output_wbuf);
let file = bufio::newscanner(file, types::SIZE_MAX);
defer bufio::finish(&file);
let saw_escape = false;
for (true) {
let byte = match (bufio::scan_byte(&file)?) {
case let byte: u8 => yield byte;
case io::EOF => break;
};
if (byte == '\x1b') {
saw_escape = true;
} else {
if (byte == '\n') {
if (saw_escape) {
fmt::fprint(&output, "\n ")?;
} else {
fmt::fprint(&output, "\n")?;
};
} else if (!ascii::iscntrl(byte: rune)) {
let fake_buffer: [1]u8 = [byte];
io::write(&output, fake_buffer)?;
};
saw_escape = false;
};
};
bufio::flush(&output)?;
};
export fn post(input: io::handle, board: str, user_name: (str | void)) (void | error) = {
validate_board_name(board)?;
let location = look_up_board(board)?;
let file = os::open(location, fs::flag::APPEND | fs::flag::WRONLY)?;
defer io::close(file)!;
let user = match(user_name) {
case let user: str => yield(user);
case void => yield "anonymous";
};
static let file_wbuf: [os::BUFSZ]u8 = [0...];
let file = bufio::init(file, [], file_wbuf);
let input = bufio::newscanner(input, types::SIZE_MAX);
defer bufio::finish(&input);
let saw_break = false;
fmt::fprintf(&file, "{}: ", user)?;
for (true) {
let byte = match (bufio::scan_byte(&input)?) {
case let byte: u8 => yield byte;
case io::EOF => break;
};
if (saw_break) {
fmt::fprint(&file, "\x1b\n")?;
saw_break = false;
};
if (byte == '\n') {
saw_break = true;
} else if (!ascii::iscntrl(byte: rune)) {
let fake_buffer: [1]u8 = [byte];
io::write(&file, fake_buffer)?;
};
};
fmt::fprint(&file, "\n")?;
bufio::flush(&file)?;
};
export fn list(output: io::handle) (void | error) = {
for (let index = 0z; index < len(bulb_path); index += 1) {
list_boards_in(output, bulb_path[index])?;
};
};
export fn look_up_board(board: str) (str | no_such_board | invalid_board) = {
validate_board_name(board)?;
static let buf = path::buffer { ... };
for (let index = 0z; index < len(bulb_path); index += 1) {
let location = path::set(&buf, bulb_path[index], board)!;
match (os::stat(location)) {
case fs::filestat => return location;
case => void;
};
};
return no_such_board;
};
export fn validate_board_name(board: str) (void | invalid_board) = {
let iter = strings::iter(board);
for (true) match (strings::next(&iter)) {
case let run: rune =>
if (
run: u32 == '/' ||
run: u32 == '.' ||
run: u32 == '\\' ||
run: u32 == ':') {
return invalid_board;
};
case done => return void;
};
};
fn list_boards_in(output: io:: handle, directory: str) (void | error) = {
let entries = os::readdir(directory)?;
defer fs::dirents_free(entries);
for (let index = 0z; index < len(entries); index += 1) {
fmt::fprintln(output, entries[index].name)?;
};
};
fn seek_to_nth_last_line(file: io::handle, number: int) (void | io::error) = {
let buffer: [256]u8 = [0...];
let end = io::seek(file, 0, io::whence::END)?;
let step = len(buffer): io::off;
let current = end;
for (true) {
// read a chunk in reverse, or stop if there is nothing left
let buffer = buffer[..];
if (current - step > 0) {
current -= step;
} else {
buffer = buffer[..current];
current = 0;
};
io::seek(file, current, io::whence::SET)?;
let length = match (io::read(file, buffer)?) {
case let length: size => yield length;
case io::EOF => break;
};
// look for line breaks in that chunk
let buffer = buffer[..length];
for (let index = len(buffer): int - 1; index >= 0; index -= 1) {
if (buffer[index: size] == '\n') number -= 1;
if (number <= 0) {
current += index;
io::seek(file, current: io::off, io::whence::SET)?;
if (current != end) {
current += 1;
io::seek(file, current, io::whence::SET)?;
};
return void;
};
};
};
return void;
};