#!/usr/bin/env luajit local filters = require "lib.filters" local json = require "lib.dkjson" local sort_orders = require "lib.sort_orders" 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 file, error_message = io.open(file_name, "r") assert(file, error_message) local result = json.decode(file:read("*all")) file:close() result._loaded_file_location = file_name return result end 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") assert(file, error_message) file:write(json.encode(data, { indent = true, })) file:write("\n") file:close() debug("Saved.") data._loaded_file_location = file_name end local get_random_order = function(array) local temporary, order = {}, {} for i = 1, #array do temporary[i] = i end while next(temporary) do order[#order + 1] = table.remove(temporary, math.random(1, #temporary)) end return order 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" 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 ] 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