{"id":29382204,"url":"https://github.com/coolbutuseless/zap","last_synced_at":"2025-07-18T12:32:46.490Z","repository":{"id":302581846,"uuid":"1012443703","full_name":"coolbutuseless/zap","owner":"coolbutuseless","description":"Fast object serialization with high compression","archived":false,"fork":false,"pushed_at":"2025-07-10T08:07:14.000Z","size":507,"stargazers_count":24,"open_issues_count":3,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2025-07-10T08:39:56.890Z","etag":null,"topics":["compression","data","library","package","pkg","r","rstats","serializing"],"latest_commit_sha":null,"homepage":"","language":"C","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/coolbutuseless.png","metadata":{"files":{"readme":"README.Rmd","changelog":"NEWS.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2025-07-02T10:43:06.000Z","updated_at":"2025-07-08T17:43:37.000Z","dependencies_parsed_at":"2025-07-03T06:33:09.260Z","dependency_job_id":"b318d638-85bf-405e-ac1f-8e42e86b0c07","html_url":"https://github.com/coolbutuseless/zap","commit_stats":null,"previous_names":["coolbutuseless/zap"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/coolbutuseless/zap","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/coolbutuseless%2Fzap","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/coolbutuseless%2Fzap/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/coolbutuseless%2Fzap/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/coolbutuseless%2Fzap/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/coolbutuseless","download_url":"https://codeload.github.com/coolbutuseless/zap/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/coolbutuseless%2Fzap/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":265760359,"owners_count":23824011,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["compression","data","library","package","pkg","r","rstats","serializing"],"created_at":"2025-07-10T04:01:10.499Z","updated_at":"2025-07-18T12:32:46.422Z","avatar_url":"https://github.com/coolbutuseless.png","language":"C","funding_links":[],"categories":[],"sub_categories":[],"readme":"---\noutput: github_document\n---\n\n\u003c!-- README.md is generated from README.Rmd. Please edit that file --\u003e\n\n```{r, include = FALSE}\nknitr::opts_chunk$set(\n  collapse = TRUE,\n  comment = \"#\u003e\",\n  fig.path = \"man/figures/README-\",\n  out.width = \"100%\"\n)\n\nlibrary(zap)\n\nif (FALSE) {\n# Check for functions which do not have an @examples block for roxygen\nsystem(\"grep -c examples man/*Rd\", intern = TRUE) |\u003e \n  grep(\":0$\", x = _, value = TRUE)\n}\n\nif (FALSE) {\n  covr::report(covr::package_coverage(\n    line_exclusions = list(\"src/zstd/zstd.c\")\n  ))\n}\n\nif (FALSE) {\n  pkgdown::build_site(override = list(destination = \"../coolbutuseless.github.io/package/zap\"))\n}\n\n# Makevars options to do some deep testing for CRAN\n\n# Type conversions are sane\n# PKG_FLAG=-Wconversion\n\n# Pointer overflow checks i.e. dodgy pointer arithmetic\n# PKG_CFLAGS+=-fsanitize=pointer-overflow -fsanitize-trap=pointer-overflow\n# Then run in the debugger:\n# R -d lldb \n# run\n# testthat::test_local()\n\n```\n\n# zap \u003cimg src=\"man/figures/logo.png\" align=\"right\" height=230/\u003e\n\n\u003c!-- badges: start --\u003e\n![](https://img.shields.io/badge/cool-useless-green.svg)\n[![CRAN](https://www.r-pkg.org/badges/version/zap)](https://CRAN.R-project.org/package=zap)\n[![R-CMD-check](https://github.com/coolbutuseless/zap/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/coolbutuseless/zap/actions/workflows/R-CMD-check.yaml)\n\u003c!-- badges: end --\u003e\n\n`zap` is an alternate serialization framework for R objects. It features \nhigh compression at fast speeds.\n\nTwo aims for this package:\n\n1. Provide an alternate serialization framework to the one built-in to R.  \n2. Write highly compressed data quickly by leveraging contextual information\n\n\n### What's in the box\n\n* `zap_read()`, `zap_write()` to read/write objects to raw vectors and files\n* `zap_version()` the version of the set of data transformations used internally\n* `zap_opts()` a way of building more detailed configuration options to use with \n  `zap()`\n* `zap_count()` a fast simple count of the bytes needed to hold\n  the *uncompressed* output of `zap_write()` (i.e. when `compress = \"none\"`)\n\n### Caveats\n\nSpeed and compression performance are very dependent on the data being serialized.\n\nThe characteristics of any floating point data will\nhave a big influence, and it is worth trying other floating point transformations\ne.g. `zap_write(x, dbl = \"shuffle\")`\n\nFor small data, there is less of a difference between the different serialization options.\n\n### Installation\n\n\u003c!-- This package can be installed from CRAN --\u003e\n\n\u003c!-- ``` r --\u003e\n\u003c!-- install.packages('zap') --\u003e\n\u003c!-- ``` --\u003e\n\nYou can install the latest development version from\n[GitHub](https://github.com/coolbutuseless/zap) with:\n\n``` r\n# install.package('remotes')\nremotes::install_github('coolbutuseless/zap')\n```\n\nPre-built source/binary versions can also be installed from\n[R-universe](https://r-universe.dev)\n\n\u003c!-- ``` r --\u003e\n\u003c!-- install.packages('zap', repos = c('https://coolbutuseless.r-universe.dev', 'https://cloud.r-project.org')) --\u003e\n\u003c!-- ``` --\u003e\n\n\n```{r echo=FALSE}\nsuppressPackageStartupMessages({\n  library(dplyr)\n  library(ggplot2)\n  library(ggrepel)\n  library(qs2)\n  library(zap)\n})\n\n\nbenchmark \u003c- function(x, skip_test = FALSE) {\n  \n  tmp \u003c- tempfile()\n  \n  if (!skip_test) {\n    enc \u003c- zap_write(x)\n    result \u003c- zap_read(enc)\n    if (!identical(result, x)) {\n      message(\"Not identical raw ---------------------------------------------------------\")\n    }\n    \n    zap_write(x, tmp)\n    result \u003c- zap_read(tmp)\n    if (!identical(result, x)) {\n      message(\"Not identical file ---------------------------------------------------------\")\n    }\n    \n  }\n  \n  res \u003c- bench::mark(\n    `saveRDS(compress=FALSE)`  = saveRDS(x, file = tmp, compress = FALSE),\n    `qs2::qs_save()`           = qs2::qs_save(x, tmp, nthreads = 1),\n    `zap_write()`              = zap_write(x, tmp),\n    # `zap_write(dbl=delta_shuffle)`   = zap_write(x, tmp, dbl = 'delta_shuffle'),\n    # `zap_write(dbl=shuffle)`   = zap_write(x, tmp, dbl = 'shuffle'),\n    # `zap_write(dbl=raw)`       = zap_write(x, tmp, dbl = 'raw'),\n    `saveRDS(zstd)`            = saveRDS(x, tmp, compress = \"zstd\"),\n    `saveRDS(xz)`              = saveRDS(x, tmp, compress = \"xz\"),\n    # ser_zstd       = writeBin(memCompress(serialize(x, NULL), 'zstd'), tmp),\n    check = FALSE\n  )\n  \n  sizes \u003c- c(\n    {saveRDS(x, file = tmp, compress = FALSE); file.size(tmp)},\n    {qs2::qs_save(x, tmp, nthreads = 1)      ; file.size(tmp)},\n    {zap_write(x, tmp); file.size(tmp)},\n    # {zap_write(x, tmp, dbl = 'delta_shuffle'); file.size(tmp)},\n    # {zap_write(x, tmp, dbl = 'shuffle'); file.size(tmp)},\n    # {zap_write(x, tmp, dbl = 'raw') ; file.size(tmp)},\n    {saveRDS(x, tmp, compress = \"zstd\")      ; file.size(tmp)},\n    {saveRDS(x, tmp, compress = \"xz\")        ; file.size(tmp)}#,\n    # {writeBin(memCompress(serialize(x, NULL), 'zstd'), tmp); file.size(tmp)}\n  )\n  \n  res \u003c- res[, 1:4]\n  res$expression \u003c- as.character(res$expression)\n  res$size \u003c- sizes\n  res\n}\n\n\n#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n# Decompression benchmark\n#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\nunbenchmark \u003c- function(x) {\n  \n  tmp \u003c- tempfile()\n  zap_write(x, tmp)\n  result \u003c- zap_read(tmp)\n  if (!identical(result, x)) {\n    message(\"Not identical ---------------------------------------------------------\")\n  }\n  \n  sizes \u003c- c(\n    {tmp01 \u003c- tempfile(); saveRDS(x, file = tmp01, compress = FALSE) ; file.size(tmp01)},\n    {tmp07 \u003c- tempfile(); qs2::qs_save(x, tmp07, nthreads = 1)       ; file.size(tmp07)},\n    {tmp03 \u003c- tempfile(); zap_write(x, tmp03 )                       ; file.size(tmp03)},\n    {tmp08 \u003c- tempfile(); saveRDS(x, tmp08, compress = \"gzip\")       ; file.size(tmp08)},\n    {tmp10 \u003c- tempfile(); saveRDS(x, tmp10, compress = \"zstd\")       ; file.size(tmp10)},\n    {tmp11 \u003c- tempfile(); saveRDS(x, tmp11, compress = \"xz\")         ; file.size(tmp11)}\n  )\n  \n  N \u003c- file.size(tmp01)\n  \n  res \u003c- bench::mark(\n    `readRDS(compress=FALSE)` = readRDS(file = tmp01),\n    `qs2::qs_read()`          = qs2::qs_read(tmp07, nthreads = 1),\n    `zap_read()`              = zap_read(tmp03),\n    `readRDS(gzip)`           = readRDS(tmp08),\n    `readRDS(zstd)`           = readRDS(tmp10),\n    `readRDS(xz)`             = readRDS(tmp11),\n    check = FALSE\n  )\n  \n  \n  \n  res \u003c- res[, 1:4]\n  res$size \u003c- sizes\n  res \n}\n\n\n\n\n#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n# \n#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\nplot_benchmark \u003c- function(res, nm = \"plot_benchmark\", sub = NULL, compress = TRUE) {\n  \n  df \u003c- res %\u003e%\n    mutate(cratio = size[1] / size) %\u003e%\n    mutate(speed = size[1] * `itr/sec` / 1024 / 1024) %\u003e%\n    mutate(\n      name = as.character(expression)\n    )\n  \n  p \u003c- ggplot(df) +\n    geom_point(aes(cratio, speed, color = as.factor(name))) + \n    ggrepel::geom_text_repel(aes(cratio, speed, label = name, color = as.factor(name)),\n                             size = 4, max.overlaps = 20) +\n    theme_bw(15) +\n    theme(legend.position = 'none') + \n    labs(\n      x = \"Compression Ratio\\n(bigger is better)\",\n      y = ifelse(compress, \n                 \"Compression Speed (MB/s)\\n(bigger is better)\",\n                 \"Decompression Speed (MB/s)\\n(bigger is better)\"),\n      title = nm,\n      subtitle = sub\n    ) + \n    # scale_y_log10() + \n    expand_limits(y = 0) +\n    scale_x_continuous(breaks = scales::pretty_breaks())\n  \n  date    \u003c- as.character(Sys.Date())\n  dirname \u003c- here::here(\"working/benchmark\", date)\n  dir.create(dirname, showWarnings = FALSE, recursive = TRUE)\n  filename \u003c- sprintf(\"%s/%s.pdf\", dirname, nm)\n  ggsave(filename, plot = p, width = 9, height = 6)\n  \n  \n  dirname \u003c- here::here(\"working/benchmark\", date, \"ZZZ\")\n  dir.create(dirname, showWarnings = FALSE, recursive = TRUE)\n  filename \u003c- sprintf(\"%s/%s.csv\", dirname, nm)\n  write.csv(res, filename, row.names = FALSE, quote = FALSE)\n  \n  \n  p\n}\n```\n\n\n\n### Example: Writing `diamonds` to file\n\nThe graph below compares different serialization/compression \noptions in R.\n\nThe x-axis is *compression ratio* - the size of the compressed data compared\nto the size of the original.  Bigger compression ratios are better.  Both `zap`\nand `xz` are able to highly compress this data.\n\nThe y-axis is *compression speed* - how quickly is the data compressed and written to file.\nSaving with `zap` is comparable in speed to saving the data uncompressed, and is \nmuch faster than `xz`.\n\n```{r eval=FALSE}\nzap_write(diamonds, dst = \"diamonds.zap\", compress = \"zstd\")\n```\n\n\n```{r echo = FALSE}\nlibrary(ggplot2)\n\nres \u003c- benchmark(diamonds)\nplot_benchmark(res, \"Writing 'diamonds' to file\")\n\nres$`itr/sec` \u003c- round(res$`itr/sec`, 1)\n\nknitr::kable(res, caption = \"Writing 'diamonds' to file\")\n```\n\n\n### Example: Reading `diamonds` from file\n\n\n```{r eval=FALSE}\nzap_read(\"diamonds.zap\")\n```\n\n```{r echo = FALSE}\nres \u003c- unbenchmark(diamonds)\nplot_benchmark(res, \"Reading 'diamonds' from file\", compress = FALSE)\n\nres$`itr/sec` \u003c- round(res$`itr/sec`, 1)\n\nknitr::kable(res, caption = \"Reading 'diamonds' from file\")\n```\n\n\n\n\n\n# `zap` Technical details\n\n### R object structure\n\nR objects are collections of SEXP elements. For the purposes of explaining \nthis package consider SEXP objects to be broken up into 3 classes.\n\n1. Atomic vectors e.g. integers, strings\n2. Containers e.g. lists, environments, closures, data.frames\n3. Everything else e.g. bytecode, dots\n\n**Atomic Vectors** are the core things users would consider *data* in R. Collections\nof integers, floating point numbers, logical values are what we actually \nwant to compute with.\n\n**Containers** allow R to organise atomic vectors into logical units - e.g.\na data.frame is a collection of (mostly) atomic vectors. A *list* is a \ncollection of other arbitrary R objects.\n\n**Everything else** encompasses all the other details of R the language that \ndon't need to be considered often by the user. E.g. the compiled bytecode\nrepresentation of a function, the actual `...` object used in function calls.\n\n### Serializing R objects\n\nBoth `zap` and R serialize objects the same way\n\n* walk along each object\n* serialize the atomic vectors it contains\n* serialize its attributes\n* for any nested containers within the object, recurse into them and serialize their contents\n\n### Compressing R objects\n\nUsing R's built-in serialization, raw bytes are compressed using entropy coders \nsuch as `gzip`, `xz` and `zstd`.\n\nThese compressors look for redundancies/patterns and calculate optimal \nways of using a smaller number of bits to represent common structures in the data.\n\nWhere `zap` differs is that it includes an extra layer of data-dependent *transformations* \nprior to compression.\n\n\n### `zap` includes lightweight transforms for improving compression\n\nUsing the custom serialization mechanism in `zap`, contextual information \nabout bytes is known as part of the process e.g. when serializing an integer\nvector, we know that each set of 4 bytes is a 32-bit signed integer represented \nin twos-complement form.\n\nKnowing the type of data that is in the bytes allows us to do some \nfast, low-memory transformations on the bytes which will:\n\n* losslessly reduce the number of bits required to represent each byte\n* make the data much more amenable to compression by the standard compressors\n\nThe following sections give a high-level overview of the transformations.  To \nfind out more details, the interested reader is directed to the C source code\nto read the implementation.\n\nNote: these transformations were arrived at after some trial and error.  They are,\nby no means, the final answer to the best transformations to use.  See the `Future work`\nsection below for some ideas on how this package might be extended/improved.\n\n# `zap` Transformations\n\n\n## Logical transformation\n\nLogical data may be `packed`:\n\n1. Take all the lowest bits of each logical value (this is the only bit which indicates\n  if the value is TRUE or FALSE). Create a bitstream with 1-bit for each value.\n2. Encode the locations of `NA` values in an auxilliary bitstream (1-bit for \n   each value).\n  \nEach logical was originally stored in 32-bit data type, and is now represented by just 2 bits \n(one in each bitstream).\n\n## Integer transformations\n\n1. `zzshuf` ZigZag encoding with delta, then byte shuffling\n2. `delta_frame` Frame-of-reference coding of the deltas (difference between consecutive elements)\n\n### Integer: `zzshuf` ZigZag encoding with byte shuffling\n\n1. [ZigZag encoding](https://lemire.me/blog/2022/11/25/making-all-your-integers-positive-with-zigzag-encoding/) to recode integers without a sign bit\n2. Take the difference between consecutive numbers.\n3. Shuffle the bytes within the vector such that it is more likely zeros\n   will be next to each other.\n    * Each integer is 4 bytes i.e ABCD, ABCD, ABCD, ...\n    * Reorder bytes to:  AAA.., BBB..., CCC..., DDD...\n\n### Integer: `delta_frame` Frame-of-reference coding of deltas\n\n[General overview of frame-of-reference coding](https://lemire.me/blog/2012/02/08/effective-compression-using-frame-of-reference-and-delta-coding/)\n\n1. Take the difference between consecutive numbers\n2. If the largest difference is \u003e= 4096, use `zzshuf` instead\n3. Find the number of bits to encode the largest difference\n4. Encode all differences with this number of bits\n5. Pack these low-bit representations of differences into 64-bit integers\n6. Encode the locations of `NA` values in an auxilliary bitstream (1-bit for \n   each number).\n\n## Factor transformation\n\nFactors may be `packed`:\n\n1. The number of levels of a factor is known without having to calculate anything\n2. If number of levels \u003e= 4096, just encode factor as an integer using `zzshuf`\n3. Find the number of bits to encode the maximum level\n4. Encode all factors with this number of bits\n5. Pack these bits into 64-bit integers\n6. NA values are encoded as zero  (since zero is not a valid factor level)\n\n\n## Character transformation\n\nEncode a character vector as a `mega` string by writing out:\n\n1. The total length of all strings\n2. The concatenation of all the nul-terminated strings (where length is \n   encoded implicitly by the position of the nul-bytes)\n3. Encode the locations of `NA` values in an auxilliary bitstream (1-bit for \n   each string).\n\nThis approach avoids encoding a separate length for each individual character string.\n\n## Floating point transformation\n\nFloating point compression is notoriously difficult, and the best transformation\nto apply is heavily dependent on the characteristics of the data.\n\n1. `shuffle` Byte shuffle \n2. `delta_shuffle` Delta and byte shuffle\n3. `alp` Adaptive Lossles floating Point compression\n\n### Floating point: `shuffle` byte shuffle\n\n1. Given each double is an 8-byte sequence: ABCDEFGH, ABCDEFGH, ...\n2. Reorder the bytes to be: AA..., BB..., CC..., DD..., EE..., FF.., GG..., HH...\n\n### Floating point: `delta_shuffle` delta and byte shuffle\n\n1. Treat every double precision float as an unsigned 64-bit integer\n2. Take the difference between consecutive values\n3. Given each value is an 8-byte sequence: ABCDEFGH, ABCDEFGH, ...\n4. Reorder the bytes to be: AA..., BB..., CC..., DD..., EE..., FF.., GG..., HH...\n\n### Floating point: `alp` Adaptive Lossless floating Point compression\n\nThis method is adapted from a *Afroozeh et al* [ALP: Adaptive Lossless floating-Point Compression](https://dl.acm.org/doi/pdf/10.1145/3626717).\nThe original [C++ code on github](https://github.com/cwida/ALP) and the \n[Rust implementation](https://github.com/spiraldb/alp) are available. Note: [the rust version is faster than c](https://spiraldb.com/post/alp-rust-is-faster-than-c)\n\n1. Examine a sample of the values\n2. Determine if these numbers represent floating point numbers with a finite \n   number of decimal places\n3. If many numbers fail this criteria, then fallback to `shuffle` or\n   `delta_shuffle` technique\n4. Determine powers of 10 to best convert numbers to integer form\n5. Convert numbers to integer form\n6. Apply differencing and byte shuffling\n7. Any individual values which were not successfully encoded are encoded in \n   an auxillary stream of \"patches\" to be applied when un-transforming the data.\n   These include `NA`, `NaN`, `Inf` as well as any floating point value not \n   convertible to an integer.\n\n\n# Future work\n\nEach of the data elements which support transformation (integer, logical, factor, double, \ncharacter) support up to 256 possible transformations.\n\nThere is room here for experimentation, new transformations and heuristics to \nchoose the \"optimal\" transformation based upon data characteristics.\n\n### Remove need for R's `serialize()`\n\nThere are some SEXPs which are still serialized using R's built-in mechanism, \nand then those raw bytes are inserted into the `zap` output.  It would\nbe nice if `zap` managed to handle all SEXPs without resorting to R.\n\nCurrent SEXPs which use R's serialization mechanism:\n\n* BCODESXP - bytecode representations\n* DOTSXP - representation of the `...` object\n* SPECIALSXP\n* BUILTIN\n* PROMSXP(?)\n* ANYSXP - not seen in real objects?\n* EXTPTRSXP\n* WEAKREFSXP\n* S4SXP\n\n\n### Bitstream \n\nCurrent bit packing occurs within unsigned 64-bit integers, and a packed element will\nnever cross from one 64-bit integer to the next.\n\nE.g. If it is known that all factor values fit into 10-bit integers, then 6 factor \nvalues will be packed into a single 64-bit destination - leaving 4 bits unused.\n\nThis packing is inefficient, but was easy to code.  \n\nIt would be worth trialling a general purpose packing routine which can pack\nbits compactly at all sizes, with no wastage.\n\n\n### Integer transformations\n\n* Try [StreamVByte](https://github.com/fast-pack/streamvbyte) for integer compression.\n  I did experiment with this early on, and may have discounted it too quickly.\n  Does it offer any speed/compression advantages over simple \"delta + shuffle\" once\n  we add zstd compression?\n\n\n### Floating point transformation\n\n* Port [Rust version of ALP](https://github.com/spiraldb/alp) to R\n\n\n\n\n\n\n\n\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcoolbutuseless%2Fzap","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcoolbutuseless%2Fzap","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcoolbutuseless%2Fzap/lists"}