From 144138cab196b633677cab6d1b70e3a679c987a1 Mon Sep 17 00:00:00 2001 From: Massimo Gengarelli Date: Sun, 27 Jul 2025 08:03:28 +0200 Subject: [PATCH 1/2] feat(cache): make it possible to ignore or clear the cache List of changes: - added a new `--no-cache` (short `-n`) option which can be prepended to the `latest`, `random` and `id` commands which will be used to forcibly ignore the cache when retrieving comics; - added a new `clearcache` command which won't print any comic, it will simply clear the SQLite cache, reclaiming disk space. --- src/gleeter.gleam | 98 +++++++++++++------- src/gleeter/application_behavior.gleam | 25 +++-- src/gleeter/cache.gleam | 28 ++++-- test/gleeter/application_behavior_test.gleam | 85 +++++++++++++---- test/gleeter/cache_test.gleam | 9 ++ 5 files changed, 176 insertions(+), 69 deletions(-) diff --git a/src/gleeter.gleam b/src/gleeter.gleam index 626c38f..6a0e37b 100644 --- a/src/gleeter.gleam +++ b/src/gleeter.gleam @@ -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, @@ -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() @@ -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() diff --git a/src/gleeter/application_behavior.gleam b/src/gleeter/application_behavior.gleam index f529d2f..b1ee3d0 100644 --- a/src/gleeter/application_behavior.gleam +++ b/src/gleeter/application_behavior.gleam @@ -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) } @@ -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 { @@ -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 } } @@ -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 } diff --git a/src/gleeter/cache.gleam b/src/gleeter/cache.gleam index 2a8daa0..ba05da4 100644 --- a/src/gleeter/cache.gleam +++ b/src/gleeter/cache.gleam @@ -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: " @@ -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) @@ -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, @@ -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:, @@ -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 = { @@ -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 = { @@ -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 +} diff --git a/test/gleeter/application_behavior_test.gleam b/test/gleeter/application_behavior_test.gleam index e7d016b..b88169d 100644 --- a/test/gleeter/application_behavior_test.gleam +++ b/test/gleeter/application_behavior_test.gleam @@ -13,58 +13,109 @@ pub fn application_behavior_tests() { ] describe("application_behavior", [ it("default behavior", fn() { - application_behavior.parse_arguments([], aliases) + application_behavior.parse_arguments([], aliases, False) |> expect.to_equal(application_behavior.Help) }), it("if id is invalid, fails silently", fn() { - application_behavior.parse_arguments(["id", "not a number"], aliases) + application_behavior.parse_arguments( + ["id", "not a number"], + aliases, + False, + ) |> expect.to_equal(application_behavior.Help) }), it("if id is valid, behavior is changed", fn() { - application_behavior.parse_arguments(["id", "14"], aliases) - |> expect.to_equal(application_behavior.WithIDComic(14)) + application_behavior.parse_arguments(["id", "14"], aliases, False) + |> expect.to_equal(application_behavior.WithIDComic(14, False)) }), + describe("cache flag", [ + it("uses the default value if no flag is specified", fn() { + application_behavior.parse_arguments(["id", "24"], aliases, False) + |> expect.to_equal(application_behavior.WithIDComic(24, False)) + }), + it("gets the value to use from the short flag", fn() { + application_behavior.parse_arguments(["-n", "random"], aliases, False) + |> expect.to_equal(application_behavior.RandomComic(True)) + }), + it("gets the value to use from the long flag", fn() { + application_behavior.parse_arguments( + ["--no-cache", "random"], + aliases, + False, + ) + |> expect.to_equal(application_behavior.RandomComic(True)) + }), + it("ignores the flag if it is it after the command", fn() { + application_behavior.parse_arguments(["id", "24", "-n"], aliases, False) + |> expect.to_equal(application_behavior.WithIDComic(24, False)) + }), + ]), describe("aliases", [ it("correctly uses a latest alias", fn() { - application_behavior.parse_arguments(["lt"], aliases) - |> expect.to_equal(application_behavior.LatestComic) + application_behavior.parse_arguments(["lt"], aliases, False) + |> expect.to_equal(application_behavior.LatestComic(False)) - application_behavior.parse_arguments(["ltst"], aliases) - |> expect.to_equal(application_behavior.LatestComic) + application_behavior.parse_arguments(["ltst"], aliases, False) + |> expect.to_equal(application_behavior.LatestComic(False)) }), it("correctly uses a random alias", fn() { - application_behavior.parse_arguments(["rnd"], aliases) - |> expect.to_equal(application_behavior.RandomComic) + application_behavior.parse_arguments(["rnd"], aliases, False) + |> expect.to_equal(application_behavior.RandomComic(False)) }), it("recognizes named aliases (bobbytables)", fn() { - application_behavior.parse_arguments(["bobbytables"], aliases) - |> expect.to_equal(application_behavior.WithIDComic(327)) + application_behavior.parse_arguments(["bobbytables"], aliases, False) + |> expect.to_equal(application_behavior.WithIDComic(327, False)) }), it("recognizes named aliases (tenthousands)", fn() { - application_behavior.parse_arguments(["tenthousands"], aliases) - |> expect.to_equal(application_behavior.WithIDComic(1053)) + application_behavior.parse_arguments(["tenthousands"], aliases, False) + |> expect.to_equal(application_behavior.WithIDComic(1053, False)) }), ]), describe("serve", [ it("witout parameters", fn() { - application_behavior.parse_arguments(["serve"], aliases) + application_behavior.parse_arguments(["serve"], aliases, False) |> expect.to_equal(application_behavior.Serve(8080, "")) }), it("port specified", fn() { - application_behavior.parse_arguments(["serve", "9000"], aliases) + application_behavior.parse_arguments(["serve", "9000"], aliases, False) |> expect.to_equal(application_behavior.Serve(9000, "")) }), it("port not an int", fn() { - application_behavior.parse_arguments(["serve", "not a number"], aliases) + application_behavior.parse_arguments( + ["serve", "not a number"], + aliases, + False, + ) |> expect.to_equal(application_behavior.Serve(8080, "")) }), it("port and path specified", fn() { application_behavior.parse_arguments( ["serve", "9000", "/xkcd"], aliases, + False, ) |> expect.to_equal(application_behavior.Serve(9000, "/xkcd")) }), ]), + describe("other commands", [ + it("detects help and --help", fn() { + application_behavior.parse_arguments(["--help"], [], False) + |> expect.to_equal(application_behavior.Help) + + application_behavior.parse_arguments(["help"], [], False) + |> expect.to_equal(application_behavior.Help) + }), + it("detects version and --version", fn() { + application_behavior.parse_arguments(["--version"], [], False) + |> expect.to_equal(application_behavior.PrintVersion) + + application_behavior.parse_arguments(["version"], [], False) + |> expect.to_equal(application_behavior.PrintVersion) + }), + it("detects the clearcache command", fn() { + application_behavior.parse_arguments(["clearcache"], [], False) + |> expect.to_equal(application_behavior.ClearCache) + }), + ]), ]) } diff --git a/test/gleeter/cache_test.gleam b/test/gleeter/cache_test.gleam index ed00653..adca535 100644 --- a/test/gleeter/cache_test.gleam +++ b/test/gleeter/cache_test.gleam @@ -164,5 +164,14 @@ pub fn cache_tests() { describe("image insert", cache_insert_image_tests(valid_cache)), describe("get comic", cache_get_comic_tests(valid_cache)), describe("count elements", cache_count_elements_tests(valid_cache)), + describe("clear cache", [ + it("can clear the cache", fn() { + valid_cache + |> cache.clear + |> cache.count_elements + |> expect.to_be_some + |> expect.to_equal(0) + }), + ]), ]) } From 352474a3086586e55675f15d19f0684ebf32761f Mon Sep 17 00:00:00 2001 From: Massimo Gengarelli Date: Sun, 27 Jul 2025 09:08:54 +0200 Subject: [PATCH 2/2] doc: update README file --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9ea4b0c..dbcd5ef 100644 --- a/README.md +++ b/README.md @@ -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 `: to show the comic with id ``. Replace `` with the desired comic ID. +* `serve ` 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/). @@ -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/` to change the behavior! - -The service is not guaranteed. +Replace `latest` with `random` or `id/` to change the behavior, you can also check [this file](./docker-digitalocean/config.toml) for a list of available aliases! ## Screenshots @@ -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 `: Fetches the comic with the specified ID and displays it in the terminal. Replace `` 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 `: Fetches the comic with the specified ID and displays it in the terminal. Replace `` 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 `: Starts a web server to serve the comics. `` is optional and defaults to 8080. `` 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 @@ -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. -