Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Gleeter
Stop interrupting your workflow for comic relief! Gleeter delivers the **exact** XKCD strip you need, *right in your terminal*. Need to gently nudge a colleague about [off-by-one errors](https://xkcd.com/3062/)? Or perhaps illustrate the superpowers of [regular expressions](https://xkcd.com/208/)? Maybe teach a colleague why it is important to [sanitize your database inputs](https://xkcd.com/327/)? Gleeter's got you covered. Install now, and weaponize your command line with the power of XKCD.

Very simple and straightforward software to fetch comics from [xkcd](https://xkcd.com) and display them in the terminal.
This is a very simple and straightforward software to fetch and display comics from [xkcd](https://xkcd.com) right in the terminal.

Supported commands are:
* `latest`: to show the latest comic
* `random`: to show a random comic
* `id <number>`: to show the comic with id `<number>`. Replace `<number>` with the desired comic ID.
* `serve <port> <path>` to create a [web server](#serve-mode-details) which you can query with curl!
* You can also define aliases for the above commands (e.g. `bobbytables`) and fetch very specific comics using the [configuration file](#configuration-file).
See the [How to use](#how-to-use) section for more information and details about the commands.

For this to work, you need a terminal which understands the [Terminal Graphics Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/).

Expand All @@ -13,13 +22,10 @@ Gleeter is known to work well with the following terminals:

## Test it out now

If you are not interested in knowing the insights of the project or to develop it, you can start using
it without installing anything: simply
You can start using it without installing anything: simply
`curl -H "X-TERMINAL-ROWS: $(tput lines)" -H "X-TERMINAL-COLUMNS: $(tput cols)" https://xkcd.massi.rocks/comics/latest`

Replace `latest` with `random` or `id/<comic_id>` to change the behavior!

The service is not guaranteed.
Replace `latest` with `random` or `id/<comic_id>` to change the behavior, you can also check [this file](./docker-digitalocean/config.toml) for a list of available aliases!

## Screenshots

Expand Down Expand Up @@ -54,10 +60,12 @@ Gleeter understands the following commands:

* `help`: Prints help information, this is also the default behavior if no arguments are provided.
* `version`: Prints the application version.
* `latest`: Fetches the latest comic from XKCD and displays it in the terminal.
* `random`: Fetches a random comic from XKCD and displays it in the terminal. Gleeter will automatically skip the comics using an unsupported format (very old comics were using the JPG extension), so you should always get a valid comic. This is a technical limitation of the Terminal Graphics Protocol.
* `id <number>`: Fetches the comic with the specified ID and displays it in the terminal. Replace `<number>` with the desired comic ID.
* `latest`: Fetches the latest comic from XKCD and displays it in the terminal. Use the `--no-cache` or `-n` flag to bypass the cache and fetch the comic directly from XKCD.
* `random`: Fetches a random comic from XKCD and displays it in the terminal. Gleeter will automatically skip the comics using an unsupported format (very old comics were using the JPG extension), so you should always get a valid comic. This is a technical limitation of the Terminal Graphics Protocol. Use the `--no-cache` or `-n` flag to bypass the cache and fetch the comic directly from XKCD. **Warning**: the `-n` (or `--no-cache` flag) **must** come before the command for it to work (e.g.: `-n random` will work, while `random -n` will not work); this behavior will be fixed in a future version.
* `id <number>`: Fetches the comic with the specified ID and displays it in the terminal. Replace `<number>` with the desired comic ID. Use the `--no-cache` or `-n` flag to bypass the cache and fetch the comic directly from XKCD. **Warning**: the `-n` (or `--no-cache` flag) **must** come before the command for it to work (e.g.: `-n id 998` will work, while `id 998 -n` will not work); this behavior will be fixed in a future version.

* `serve <port> <base_path>`: Starts a web server to serve the comics. `<port>` is optional and defaults to 8080. `<base_path>` is also optional and defaults to "". For example, `gleeter serve 3000 /comics` will start the server on port 3000 and serve the comics under the `/comics` path.
* `clearcache`: Clears the local cache of comics.

## Configuration File

Expand Down Expand Up @@ -328,4 +336,3 @@ Gleeter is licensed under the MIT License. See [LICENSE.txt](LICENSE.txt) for de
All xkcd comics displayed by Gleeter are licensed under a Creative Commons license. The intellectual property of xkcd.com belongs to Randall Munroe. Contact details can be found on the xkcd.com website.

I am in no way responsible for the content of the xkcd comics. All attributions and inquiries should be directed to Randall Munroe.

98 changes: 62 additions & 36 deletions src/gleeter.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ fn print_api_error(in: xkcd.APIError) -> Nil {
}
}

fn print_graphics_error(in: graphics.GraphicsError) -> Nil {
case in {
graphics.ChunkSizeTooBig -> io.println("Chunk size is too big!")
graphics.ChunkNotMultipleOf4 ->
io.println("Chunk size is not a multiple of 4!")
}
}

fn print_version() -> Result(Nil, Nil) {
Ok(io.println(
"gleeter v" <> version.gleeter_version <> " " <> version.github_url,
Expand All @@ -45,71 +53,85 @@ fn get_comic_from_cache(
case cache.get_comic(cache, id) {
option.Some(cd) -> Ok(cd)
option.None -> {
let xkcd = case id {
0 -> xkcd.get_latest()
x -> xkcd.get_comic(x)
}
use xkcd <- result.try(
xkcd
|> result.map_error(print_api_error),
)
use raw_data <- result.try(
xkcd.get_image(xkcd) |> result.map_error(print_api_error),
)

use body <- result.try(
graphics.to_kitty_protocol_string(raw_data, 4096)
|> result.map_error(fn(e) {
case e {
graphics.ChunkSizeTooBig -> io.println("Chunk size is too big!")
graphics.ChunkNotMultipleOf4 ->
io.println("Chunk size is not a multiple of 4!")
}
}),
use cache.ComicWithData(xkcd, body, raw_data) as cmd <- result.try(
get_comic_without_cache(id),
)

cache.insert_comic(cache, xkcd)
|> cache.insert_image(xkcd.number, body, raw_data)

Ok(cache.ComicWithData(xkcd, body, raw_data))
Ok(cmd)
}
}
}

// Get a comic without using the cache
fn get_comic_without_cache(id: Int) -> Result(cache.ComicWithData, Nil) {
let xkcd = case id {
0 -> xkcd.get_latest()
x -> xkcd.get_comic(x)
}
use xkcd <- result.try(
xkcd
|> result.map_error(print_api_error),
)
use raw_data <- result.try(
xkcd.get_image(xkcd) |> result.map_error(print_api_error),
)

use body <- result.try(
graphics.to_kitty_protocol_string(raw_data, 4096)
|> result.map_error(print_graphics_error),
)

Ok(cache.ComicWithData(xkcd, body, raw_data))
}

// Generic get comic function, the request is then routed to the right underneath function
fn get_comic(
in: PrintComic,
cache: cache.Cache,
ignore_cache: Bool,
) -> Result(cache.ComicWithData, Nil) {
case in {
Latest -> get_comic_from_cache(cache, 0)
ID(id) -> get_comic_from_cache(cache, id)
Random -> {
case in, ignore_cache {
Latest, _ -> get_comic_from_cache(cache, 0)
ID(id), False -> get_comic_from_cache(cache, id)
ID(id), True -> get_comic_without_cache(id)
Random, ignore_cache -> {
use xkcd.Xkcd(number: highest, ..) <- result.try(
xkcd.get_latest() |> result.map_error(print_api_error),
)
let random_comic = int.random(highest)
use cache.ComicWithData(xkcd.Xkcd(img_url:, ..), ..) as r <- result.try(
get_comic_from_cache(cache, random_comic),
case ignore_cache {
False -> get_comic_from_cache(cache, random_comic)
True -> get_comic_without_cache(random_comic)
},
)
let uri_string = uri.to_string(img_url)
case utils.is_jpeg(uri_string) {
False -> Ok(r)
True -> {
debug_print("Skipping JPEG comic: " <> uri_string)
get_comic(in, cache)
get_comic(in, cache, ignore_cache)
}
}
}
}
}

// Prints a comic to the stdout
fn print_comic(
cache: cache.Cache,
config: config.Configuration,
in: PrintComic,
ignore_cache: Bool,
) -> Result(Nil, Nil) {
use cached_comic <- result.try(get_comic(in, cache))
let cache.ComicWithData(xkcd, body, raw_data) = cached_comic
use cache.ComicWithData(xkcd, body, raw_data) <- result.try(get_comic(
in,
cache,
ignore_cache,
))
use image_size <- result.try(png.get_image_size(raw_data))
let terminal_size = graphics.get_terminal_size()

Expand Down Expand Up @@ -190,15 +212,19 @@ pub fn main() -> Result(Nil, Nil) {

let r = case application_behavior.get_application_behavior(configuration) {
application_behavior.PrintVersion -> print_version()
application_behavior.LatestComic ->
print_comic(cache, configuration, Latest)
application_behavior.RandomComic ->
print_comic(cache, configuration, Random)
application_behavior.WithIDComic(id) ->
print_comic(cache, configuration, ID(id))
application_behavior.LatestComic(ignore_cache) ->
print_comic(cache, configuration, Latest, ignore_cache)
application_behavior.RandomComic(ignore_cache) ->
print_comic(cache, configuration, Random, ignore_cache)
application_behavior.WithIDComic(id, ignore_cache) ->
print_comic(cache, configuration, ID(id), ignore_cache)
application_behavior.Serve(p, b) ->
serve.serve(p, b, cache, configuration) |> Ok
application_behavior.Help -> print_help(configuration.aliases)
application_behavior.ClearCache -> {
cache.clear(cache)
io.println("Cache cleared") |> Ok
}
}
let end = birl.now()

Expand Down
25 changes: 15 additions & 10 deletions src/gleeter/application_behavior.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import gleam/result
import gleeter/config

pub type ApplicationBehavior {
RandomComic
LatestComic
WithIDComic(id: Int)
RandomComic(ignore_cache: Bool)
LatestComic(ignore_cache: Bool)
WithIDComic(id: Int, ignore_cache: Bool)
PrintVersion
Help
ClearCache
Serve(port: Int, base_path: String)
}

Expand All @@ -18,7 +19,7 @@ pub fn get_application_behavior(
) -> ApplicationBehavior {
let args = argv.load().arguments

parse_arguments(args, cfg.aliases)
parse_arguments(args, cfg.aliases, False)
}

fn parse_serve(args: List(String)) -> ApplicationBehavior {
Expand All @@ -38,15 +39,19 @@ fn parse_serve(args: List(String)) -> ApplicationBehavior {
pub fn parse_arguments(
args: List(String),
aliases: List(config.Alias),
ignore_cache: Bool,
) -> ApplicationBehavior {
case args {
[] | ["help", ..] | ["--help", ..] -> Help
["--no-cache", ..rest] | ["-n", ..rest] ->
parse_arguments(rest, aliases, True)
["version", ..] | ["--version", ..] -> PrintVersion
["random", ..] -> RandomComic
["latest", ..] -> LatestComic
["random", ..] -> RandomComic(ignore_cache)
["latest", ..] -> LatestComic(ignore_cache)
["clearcache", ..] -> ClearCache
["id", id, ..] -> {
case int.parse(id) {
Ok(id) -> WithIDComic(id)
Ok(id) -> WithIDComic(id, ignore_cache)
_ -> Help
}
}
Expand All @@ -55,9 +60,9 @@ pub fn parse_arguments(
case list.filter(aliases, fn(x) { x.name == alias }) |> list.first() {
Ok(alias) ->
case alias {
config.IdAlias(_, id) -> WithIDComic(id)
config.LatestAlias(_) -> LatestComic
config.RandomAlias(_) -> RandomComic
config.IdAlias(_, id) -> WithIDComic(id, ignore_cache)
config.LatestAlias(_) -> LatestComic(ignore_cache)
config.RandomAlias(_) -> RandomComic(ignore_cache)
}
Error(_) -> Help
}
Expand Down
28 changes: 22 additions & 6 deletions src/gleeter/cache.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ const count_elements_query = "
select count(1) from comics
"

const clear_cache_query = "
delete from images;
delete from comics;
vacuum main;
"

fn convert_sqlight_error(in: sqlight.Error) -> String {
let sqlight.SqlightError(_, desc, code) = in
"sqlight error: "
Expand All @@ -77,14 +83,14 @@ fn convert_sqlight_error(in: sqlight.Error) -> String {
}
}

fn with_cache_insert(cache: Cache, f: fn(Connection) -> Cache) -> Cache {
fn with_cache_exec(cache: Cache, f: fn(Connection) -> Cache) -> Cache {
case cache {
Faulty(_) -> cache
Cache(db:, ..) -> f(db)
}
}

fn with_cache_select(cache: Cache, f: fn(Connection) -> Option(a)) -> Option(a) {
fn with_cache_query(cache: Cache, f: fn(Connection) -> Option(a)) -> Option(a) {
case cache {
Faulty(_) -> option.None
Cache(db:, ..) -> f(db)
Expand Down Expand Up @@ -129,7 +135,7 @@ pub fn insert_image(
raw_data: BitArray,
) -> Cache {
debug_print("Inserting image for comic " <> int.to_string(comic_number))
use db <- with_cache_insert(cache)
use db <- with_cache_exec(cache)
let insert_result =
sqlight.query(
insert_table_image_query,
Expand All @@ -152,7 +158,7 @@ pub fn insert_image(

pub fn insert_comic(cache: Cache, comic comic: xkcd.Xkcd) -> Cache {
debug_print("Inserting comic " <> int.to_string(comic.number))
use db <- with_cache_insert(cache)
use db <- with_cache_exec(cache)
let xkcd.Xkcd(
number:,
publication_date:,
Expand Down Expand Up @@ -196,7 +202,7 @@ pub fn insert_comic(cache: Cache, comic comic: xkcd.Xkcd) -> Cache {
}

pub fn get_comic(cache: Cache, id number: Int) -> Option(ComicWithData) {
use db <- with_cache_select(cache)
use db <- with_cache_query(cache)
debug_print("Getting comic " <> int.to_string(number))

let decoder = {
Expand Down Expand Up @@ -243,7 +249,7 @@ pub fn get_comic(cache: Cache, id number: Int) -> Option(ComicWithData) {
}

pub fn count_elements(cache: Cache) -> option.Option(Int) {
use db <- with_cache_select(cache)
use db <- with_cache_query(cache)
debug_print("Counting elements")

let decoder = {
Expand All @@ -263,3 +269,13 @@ pub fn is_cache_loaded(cache: Cache) -> Bool {
Cache(_, _, _) -> True
}
}

pub fn clear(cache: Cache) -> Cache {
use db <- with_cache_exec(cache)
case sqlight.exec(clear_cache_query, db) {
Ok(_) -> debug_print("Cleared cache")
Error(e) -> debug_print(convert_sqlight_error(e))
}

cache
}
Loading