Compare commits

..
21 Commits
Author SHA1 Message Date
tangent 1c7e66e38e Update ReadMe.md
Make it more clear how the fractional part of hours is intended to be minutes. Whoops.
2026-08-06 18:39:19 -06:00
tangent e1fa8e1916 beginning support for hours - for audiobook tracking 2026-08-06 18:37:30 -06:00
tangent 8dce67b2c4 customizable number of items to print and access on launch
this also fixes an issue where item 10 (displayed as 0) was unselectable
2026-08-06 18:16:42 -06:00
tangent ae484aa134 reorganizing 2026-08-06 17:59:36 -06:00
tangent 7e3b9f43d1 launch() needs to save when it corrects the data structure so it doesn't have to do it every time 2026-08-06 16:43:49 -06:00
tangent 9c2d209de3 How did I forget this end? -.-
The gitea editor was bugging out with character entry,
but I didn't expect it to fail a whole word. It probably was
on me, but idk because of that.
2026-08-06 16:41:23 -06:00
tangent 5fa8c1a974 close #13 making the flow of sort functions more obvious
The commit closing #6 was somehow broken anyhow.
2026-08-06 16:39:31 -06:00
tangent 20f70aed7f close #5 the data knows where it is, and the file doesn't 2026-08-04 21:15:05 -06:00
tangent 33e94e2ef0 close #1 recalculate_total_pages() added 2026-08-04 21:08:52 -06:00
tangent dfe74510ef close #6 total_pages is being used for sorting 2026-08-04 20:56:24 -06:00
tangent 856e483570 close #2 import duplicates are detected and ignored 2026-08-04 20:45:51 -06:00
tangent 8e2211fd04 close #7 detect_type() added 2026-08-04 20:27:09 -06:00
tangent ff674c0273 Corrected repo URLs in ReadMe 2026-08-03 16:07:15 -06:00
tangent b458994149 print_list added :D 2026-08-01 05:28:25 -06:00
tangent 39e7cdc8a8 fixing typoes, better est. avg. pg. count 2026-08-01 05:13:39 -06:00
tangent 362d332a71 somehow the file wasn't saved so the priority fix from the last commit is here instead 2026-08-01 04:14:19 -06:00
tangent 4cdf2a7322 books can be added
- debug function added
- updating progress added
- book select/add multifunction (ew sounds like corpo-speak)
- fixed initial priority assignment to be randomized
- pulled out preset function
- normal exit capability (lol)
2026-08-01 04:13:34 -06:00
tangent 6f0cbfcd2a fix data error in default sort, move selecting books to own function 2026-07-31 23:37:01 -06:00
tangent 942855d5f2 fix import, explicit data passing between functions 2026-07-31 23:22:46 -06:00
tangent 22e81227b9 reorg -> function split 2026-07-31 22:29:26 -06:00
tangent ae9d04ffa7 mostly implemented an import system 2026-07-31 20:51:08 -06:00
4 changed files with 498 additions and 87 deletions
+29 -15
View File
@@ -2,48 +2,62 @@
CLI tracker to prioritize your limited time by ELO ranking media. CLI tracker to prioritize your limited time by ELO ranking media.
## Initial Ideas ## Initial Ideas
- [ ] Mark books read/unread/in-progress. - [x] Mark books read/unread/in-progress.
- [ ] Show top 10 / bottom 10. Show only in-progress or unread. - [x] Show top 10 / bottom 10. Show only in-progress or unread.
- [ ] Rank only in-progress or unread. - [ ] Rank only in-progress or unread.
- [ ] Track books by title, author, and series. Default is to recognize "by" or "(series)" and automatically sort into relevant fields. - [x] Track books by title, author, and series. Default is to recognize "by" or "(series)" and automatically sort into relevant fields.
- [ ] But also almost nothing is actually *needed*. - [x] But also almost nothing is actually *needed*.
- [x] Store progress as a numerical value? (Unread = 0, read = 1, in-progress in-between? Too easy to forget how it works?)
- [x] Store pages as an option, - [x] Store pages as an option,
- [ ] allow a weighted priority based on remaining percentage or whatever. - [x] allow a weighted priority based on remaining percentage or whatever.
- [ ] Store items as an array instead of object, because that makes the JSON output more consistent -> smaller JSON commit differences. - [x] Store items as an array instead of object, because that makes the JSON output more consistent -> smaller JSON commit differences.
- [ ] I am not sure this actually helps much when EACH ITEM is still an
I think the opening UI should be to display the top 10 books in-progress (sort order customizable by user), where pressing a number key object. I need to make the JSON export sort its keys.
allows the user to immediately enter an updated progress. Additional alphabetic keys are an option to access other features. This way - [ ] Re-implement ELO ranking.
tracker use is near frictionless. (0 maps to 10) - [ ] Display filter/sort options to output lists. (Focus only on top 10 items?)
## Contributions ## Contributions
This repo is hosted at https://gitea.tangentfox.com/tangent/Asymtome and This repo is hosted at https://gitea.tangentfox.com/tangent/Asymptome and
force-pushed to https://github.com/TangentFoxy/Asymtome when commits are made. force-pushed to https://github.com/TangentFoxy/Asymptome when commits are made.
I accept pull requests and issues, but management of the code is handled there. I accept pull requests and issues, but management of the code is handled there.
Licensing? I don't care. Do whatever you want. IP rights are stupid. Licensing? I don't care. Do whatever you want. IP rights are stupid.
## Ideas
- [ ] When importing, allow user to make a choice when duplicates are detected
instead of rejecting them automatically.
- [ ] When importing, a duplicate should still be checked for missing data, and
merge rather than ignore.
## Book Data Structure ## Book Data Structure
```json ```json
{ {
"title": "Title", "title": "Title",
"author": "Author", "author": "Author",
"series": "Series", "series": "Series",
"genre": "Fiction",
"pages": 100, "pages": 100,
"hours": 1.30,
"priority": 0, "priority": 0,
"progress": 0.5, "progress": 0.5,
"hidden": true "hidden": true
} }
``` ```
- `title`, `author`, `series`, `pages`, and `hidden` are optional. - `title`, `author`, `series`, `pages`, `hours`, `genre`, and `hidden` are
- At least one of `title`, `author`, or `series` must be defined. optional.
- At least one of `title`, `author`, `series`, or `genre` must be defined.
- `priority` is the ELO ranking, initializing to `0`. - `priority` is the ELO ranking, initializing to `0`.
- `progress` represents read/unread/in-progress status. `0` is unread, `1` is - `progress` represents read/unread/in-progress status. `0` is unread, `1` is
read, and anything in-between in a percentage of progress. read, and anything in-between in a percentage of progress.
- `hidden` is undefined or `true` to hide a book from ALL of the interface - `hidden` is undefined or `true` to hide a book from ALL of the interface
without deleting its data. without deleting its data.
- `hours` is hours.minutes rather than the fractional part being percentage of
an hour.
Despite the script supporting otherwise, I highly recommend only using this for
*specific book titles*. In my experience, using entries representing multiple
books makes it more difficult to prioritize and use.
## Why "Asymptome" ? ## Why "Asymptome" ?
A combination of asymptote - because you will die before you finish your reading A combination of asymptote - because you will die before you finish your reading
Regular → Executable
+383 -72
View File
@@ -1,8 +1,12 @@
#!/usr/bin/env luajit #!/usr/bin/env luajit
local filters = require "lib.filters"
local json = require "lib.dkjson" local json = require "lib.dkjson"
local sort_orders = require "lib.sort_orders"
math.randomseed(os.time()) math.randomseed(os.time())
local default_file = "books.json" local default_file = "books.json"
local debug = function() end
-- local debug = function(...) print(...) end
local path_exists = function(path_name) local path_exists = function(path_name)
local file = io.open(path_name, "r") local file = io.open(path_name, "r")
@@ -18,15 +22,23 @@ local load_json = function(file_name)
assert(file, error_message) assert(file, error_message)
local result = json.decode(file:read("*all")) local result = json.decode(file:read("*all"))
file:close() file:close()
result._loaded_file_location = file_name
return result return result
end end
local save_json = function(file_name, data) local save_json = function(data)
local file_name = data._loaded_file_location
data._loaded_file_location = nil
local file, error_message = io.open(file_name, "w") local file, error_message = io.open(file_name, "w")
assert(file, error_message) assert(file, error_message)
file:write(json.encode(data, { indent = true, })) file:write(json.encode(data, { indent = true, }))
file:write("\n") file:write("\n")
file:close() file:close()
debug("Saved.")
data._loaded_file_location = file_name
end end
local get_random_order = function(array) local get_random_order = function(array)
@@ -40,79 +52,378 @@ local get_random_order = function(array)
return order return order
end end
local filters = { local get_display_name = function(book)
all_books = function(book) local result = ""
if book.hidden then return false end
return true
end,
unread = function(book)
if book.hidden then return false end
if book.progress == 0 then
return true
end
return false
end,
read = function(book)
if book.hidden then return false end
if book.progress == 1 then
return true
end
return false
end,
in_progress = function(book)
if book.hidden then return false end
if book.progress > 0 and book.progress < 1 then
return true
end
return false
end,
}
local sort_orders if book.title then
sort_orders = { result = book.title
most_progress = function(A, B) if book.author then
return A.progress > B.progress result = result .. " by " .. book.author
end,
least_progress = function(A, B)
return A.progress < B.progress
end,
highest_priority = function(A, B)
return A.priority > B.priority
end,
lowest_priority = function(A, B)
return A.priority < B.priority
end,
most_pages_remaining = function(A, B)
if not (A.pages and B.pages) then -- if pages is unknown for both, fallback to percentage
return A.progress > B.progress
end end
local average_page_count = data.total_pages / #data.books elseif book.author then
local a = (A.pages or average_page_count) - (A.pages or average_page_count) * A.progress result = book.author .. " (author)"
local b = (B.pages or average_page_count) - (B.pages or average_page_count) * B.progress end
return a > b
end,
fewest_pages_remaining = function(A, B)
return not sort_orders.most_pages_remaining(A, B)
end,
}
local data if book.series then
if path_exists(default_file) then if #result > 0 then
data = load_json(default_file) result = result .. " (" .. book.series .. ")"
-- TODO verify data structure else
else result = book.series .. " (series)"
data = { end
books = {}, end
elo = {
distance_constant = 5, if book.genre then
maximum_change = 2.5, if #result > 0 then
}, result = result .. " (" .. book.genre .. ")"
total_pages = 0, else
defaults = { result = book.genre .. " (genre)"
launch = { end
filter = "in_progress", end
sort = "fewest_pages_remaining",
if book.pages then
result = result .. " ~" .. math.floor(book.progress * book.pages) .. "/" .. book.pages .. " pages read (" .. math.floor(book.progress * 100) .. "%)"
elseif book.hours then
-- TODO correct for the fractional part being minutes instead of percent
result = result .. " ~" .. math.floor(book.progress * book.hours * 10) / 10 .. "/" .. book.hours .. " hours listened (" .. math.floor(book.progress * 100) .. "%)"
elseif book.progress > 0 and book.progress < 1 then
result = result .. " ~" .. math.floor(book.progress * 100) .. "% read"
end
return result
end
local update_progress_prompt = function(book)
if not book then
print("Invalid selection.")
return false
end
print("Enter a value between 0 and 1 to update progress:")
print(" " .. get_display_name(book))
local input = io.read("*line")
local numerical = tonumber(input)
if numerical >= 0 and numerical <= 1 then
book.progress = numerical
return true
end
-- saving is handled by a higher function on the stack
end
local detect_type = function(input)
local author, series, genre
local i = input:find(" %(.-series.-%)")
if i then
series = input:sub(1, i - 1)
end
local i = input:find(" %(trilogy%)")
if i then
series = input:sub(1, i - 1)
end
local i = input:find(" %(.-author%)")
if i then
author = input:sub(1, i - 1)
end
local i = input:find(" %(genre%)")
if i then
genre = input:sub(1, i - 1)
end
return author, series, genre
end
local get_book = function(data, input)
local title, author, series, genre
local i, j = input:find(" by ")
if i then
title = input:sub(1, i - 1)
author = input:sub(j + 1)
debug("get_book() Title by Author detected:", title, author)
else
author, series, genre = detect_type(input)
if not (author or series or genre) then
title = input
end
end
local book
for i = 1, #data.books do
local current = data.books[i]
if title then
if current.title == title then
if author then
if current.author and current.author == author then
book = current
break
end
else
book = current
break
end
end
elseif author and current.author == author then
book = current
break
elseif series and current.series == series then
book = current
break
elseif genre and current.genre == genre then
book = current
break
end
end
if not book then
book = {
title = title,
author = author,
series = series,
genre = genre,
progress = 0,
priority = math.random(),
}
data.books[#data.books + 1] = book
end
-- this may or may not stay relevant to this placement
return update_progress_prompt(book)
end
local update_book = function(book)
-- currently just a wrapper, because I'm not sure if this will stay exactly consistent with that
return update_progress_prompt(book)
end
local book_exists = function(data, book)
for i = 1, #data.books do
local current = data.books[i]
if not (book.title == current.title) then
break
end
if not (book.author == current.author) then
break
end
if not (book.series == current.series) then
break
end
if not (book.genre == current.genre) then
break
end
return current -- we return the object instead of true so it can be updated if desired
end
return false
end
local recalculate_total_pages = function(data)
data.total_pages = 0
data.books_with_page_count = 0
for i = 1, #data.books do
local book = data.books[i]
if book.pages then
data.total_pages = data.total_pages + book.pages
data.books_with_page_count = data.books_with_page_count + 1
end
end
end
local import_json = function(data, file_name)
import = load_json(file_name)
if import.books then
for i = 1, #import.books do
-- TODO allow updating metadata instead of just ignoring
if not book_exists(data, import.books[i]) then
data.books[#data.books + 1] = import.books[i]
end
end
else -- assume we are importing from decider.lua's format
for title, value in pairs(import) do
local run = function()
local book = { -- minimum required book data strucutre
priority = math.random(),
progress = 0,
}
book.author, book.series, book.genre = detect_type(title)
if not (book.series or book.author or book.genre) then
local i, j = title:find(" by ")
if i then
book.title = title:sub(1, i - 1)
book.author = title:sub(j + 1)
else
book.title = title
end
end
if type(value) == "number" then
book.priority = value
else
book.priority = value.score
if value.done == true then
book.progress = 1
elseif value.done == false then
book.hidden = true
end
end
-- TODO allow updating metadata (this and the above need to be split into its own function)
if not book_exists(data, book) then
data.books[#data.books + 1] = book
end
end
run()
end
end
recalculate_total_pages(data)
-- we must rely on parent function to save
end
local get_books_by_preset = function(data, preset_name)
local selected_books = {}
for i = 1, #data.books do
local book = data.books[i]
if filters[data.defaults[preset_name].filter](book) then
selected_books[#selected_books + 1] = book
end
end
table.sort(selected_books, function(A, B) return sort_orders[data.defaults[preset_name].sort](A, B, data) end)
return selected_books
end
local print_list = function(data, options)
local gsplit = function(s, delimiter)
local function escape_special_characters(s)
local special_characters = "[()%%.[^$%]*+%-?]"
if s == nil then return end
return (s:gsub(special_characters, "%%%1"))
end
delimiter = delimiter or ","
if s:sub(-#delimiter) ~= delimiter then s = s .. delimiter end
return s:gmatch("(.-)" .. escape_special_characters(delimiter))
end
local split = function(s, delimiter)
local result = {}
for item in gsplit(s, delimiter) do
result[#result + 1] = item
end
return result
end
options = split(options, " ")
local selected_books = {}
for i = 1, #data.books do
local book = data.books[i]
if filters[options[1]](book) then
selected_books[#selected_books + 1] = book
end
end
table.sort(selected_books, function(A, B) return sort_orders[options[2]](A, B, data) end)
local limit = 10
if options[3] == "all" then
limit = #selected_books
end
print("")
for i = 1, limit do
local book = selected_books[i]
print(i .. ". " .. get_display_name(book))
end
print(" Press enter to continue.")
io.read("*line")
end
local launch = function(file_name)
local data
if path_exists(file_name) then
data = load_json(file_name)
-- TODO verify data structure
local data_modified = false
if not data.books_with_page_count then
recalculate_total_pages(data)
data_modified = true
end
if not data.defaults.launch.max_displayed then
data.defaults.launch.max_displayed = 10
data_modified = true
end
if data_modified then
save_json(data)
end
else
data = {
books = {},
elo = {
distance_constant = 5,
maximum_change = 2.5,
}, },
}, total_pages = 0,
} books_with_page_count = 0,
defaults = {
launch = {
filter = "in_progress",
sort = "fewest_pages_remaining",
},
},
}
save_json(data)
end
local selected_books = get_books_by_preset(data, "launch")
return data, selected_books
end
local main = function(data, selected_books)
local book_display_count = data.defaults.launch.max_displayed == 0 and #selected_books or data.defaults.launch.max_displayed
for i = 1, book_display_count do
local book = selected_books[i]
print(" " .. i .. ". " .. get_display_name(book))
end
print("Commands: " .. (#selected_books > 0 and "[#] to modify a book's progress. " or "") .. "[i <file>] to import from a JSON")
print(" file. [recalculate pages] Enter nothing to exit.")
print(" Adding/Selecting: Title OR Title by Author OR \"Name (type)\" for other types.")
print(" Listing: list [filter] [sort] (all)")
-- TODO implement elo ranking
local input = io.read("*line")
local numerical = tonumber(input)
if #input == 0 then
os.exit(0)
elseif numerical then
update_book(selected_books[numerical])
elseif input:sub(1, 1) == "i" then
import_json(data, input:sub(3))
elseif input:find("list ") == 1 then
print_list(data, input:sub(6))
return true
elseif input:find("recalculate pages") == 1 then
recalculate_total_pages(data)
else -- assume we are trying to add/select a book
get_book(data, input)
-- but that just returns a book, we need to DO something with it ??
-- right now, it demands a progress update;
-- TODO add mode selection to select and update vs select only mode that THEN asks what to do?
-- no? these are completely separate modes, there should be quick add, update progress, rank mode, add pages mode, etc
end
save_json(data)
return true
end
local data, selected_books = launch(default_file)
while true do
main(data, selected_books)
end end
+43
View File
@@ -0,0 +1,43 @@
local filters = {
all_books = function(book)
if book.hidden then return false end
return true
end,
unread = function(book)
if book.hidden then return false end
if book.progress == 0 then
return true
end
return false
end,
read = function(book)
if book.hidden then return false end
if book.progress == 1 then
return true
end
return false
end,
in_progress = function(book)
if book.hidden then return false end
if book.progress > 0 and book.progress < 1 then
return true
end
return false
end,
exclude_series = function(book)
if book.hidden then return false end
if book.series and not (book.title or book.author) then
return false
end
return true
end,
audiobooks_only = function(book)
if book.hidden then return false end
if book.hours then
return true
end
return false
end,
}
return filters
+43
View File
@@ -0,0 +1,43 @@
local sort_orders
sort_orders = {
most_progress = function(A, B)
return A.progress > B.progress
end,
least_progress = function(A, B)
return A.progress < B.progress
end,
highest_priority = function(A, B)
return A.priority > B.priority
end,
lowest_priority = function(A, B)
return A.priority < B.priority
end,
most_pages_remaining = function(A, B, data)
if not (A.pages and B.pages) then -- if pages is unknown for both, fallback to percentage
return A.progress < B.progress
end
local average_page_count = data.total_pages / data.books_with_page_count
if not (average_page_count == average_page_count) then
average_page_count = 300 -- based on average novel length
end
local a = (A.pages or average_page_count) - (A.pages or average_page_count) * A.progress
local b = (B.pages or average_page_count) - (B.pages or average_page_count) * B.progress
return a > b
end,
fewest_pages_remaining = function(A, B, data)
return not sort_orders.most_pages_remaining(A, B, data)
end,
most_time_remaining = function(A, B)
if not (A.hours and B.hours) then -- fallback to percentage
return A.progress < B.progress
end
local a = A.hours - A.hours * A.progress
local b = B.hours - B.hours * B.progress
return a > b
end,
least_time_remaining = function(A, B)
return not sort_orders.most_time_remaining(A, B)
end,
}
return sort_orders