Compare commits

...
24 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
tangent f4efb0f48a more prep work 2026-07-31 19:26:23 -06:00
tangent e5b08b0873 fix json formatting in ReadMe 2026-07-31 19:16:17 -06:00
tangent 30c28f1c45 filters, sort orders, beginning of data structure 2026-07-31 19:15:59 -06:00
4 changed files with 538 additions and 24 deletions
+51 -22
View File
@@ -2,36 +2,65 @@
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
object. I need to make the JSON export sort its keys.
- [ ] Re-implement ELO ranking.
- [ ] Display filter/sort options to output lists. (Focus only on top 10 items?)
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 ## Contributions
allows the user to immediately enter an updated progress. Additional alphabetic keys are an option to access other features. This way
tracker use is near frictionless. (0 maps to 10)
## data structure of an item This repo is hosted at https://gitea.tangentfox.com/tangent/Asymptome and
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.
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
```json ```json
{ {
title = "Title", "title": "Title",
author = "Author", "author": "Author",
series = "Series", "series": "Series",
pages = 100, "genre": "Fiction",
priority = 0, "pages": 100,
progress = 0.5, "hours": 1.30,
"priority": 0,
"progress": 0.5,
"hidden": true
} }
``` ```
- `title`, `author`, `series`, and `pages` 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 read, and anything in-between in a percentage of progress. - `progress` represents read/unread/in-progress status. `0` is unread, `1` is
read, and anything in-between in a percentage of progress.
- `hidden` is undefined or `true` to hide a book from ALL of the interface
without deleting its data.
- `hours` is hours.minutes rather than the fractional part being percentage of
an hour.
## the name Despite the script supporting otherwise, I highly recommend only using this for
A combination of asymptote - because you will die before you finish your reading list, always getting closer but never arriving - and tome. *specific book titles*. In my experience, using entries representing multiple
books makes it more difficult to prioritize and use.
## Why "Asymptome" ?
A combination of asymptote - because you will die before you finish your reading
list, always getting closer but never arriving - and tome.
It's also a-symptom of my neuroses. :D
Regular → Executable
+401 -2
View File
@@ -1,24 +1,47 @@
#!/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 debug = function() end
-- local debug = function(...) print(...) end
local path_exists = function(path_name)
local file = io.open(path_name, "r")
if file then
file:close()
return true
end
return false
end
local load_json = function(file_name) local load_json = function(file_name)
local file, error_message = io.open(file_name, "r") local file, error_message = io.open(file_name, "r")
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 random_order = function(array) local get_random_order = function(array)
local temporary, order = {}, {} local temporary, order = {}, {}
for i = 1, #array do for i = 1, #array do
temporary[i] = i temporary[i] = i
@@ -28,3 +51,379 @@ local random_order = function(array)
end end
return order return order
end end
local get_display_name = function(book)
local result = ""
if book.title then
result = book.title
if book.author then
result = result .. " by " .. book.author
end
elseif book.author then
result = book.author .. " (author)"
end
if book.series then
if #result > 0 then
result = result .. " (" .. book.series .. ")"
else
result = book.series .. " (series)"
end
end
if book.genre then
if #result > 0 then
result = result .. " (" .. book.genre .. ")"
else
result = book.genre .. " (genre)"
end
end
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
+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