first commit
This commit is contained in:
490
mpv/scripts/file-browser/modules/utils.lua
Normal file
490
mpv/scripts/file-browser/modules/utils.lua
Normal file
@@ -0,0 +1,490 @@
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
-----------------------------------------Utility Functions----------------------------------------------
|
||||
---------------------------------------Part of the addon API--------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
|
||||
local input_loaded, input = pcall(require, 'mp.input')
|
||||
local user_input_loaded, user_input = pcall(require, 'user-input-module')
|
||||
|
||||
--creates a table for the API functions
|
||||
--adds one metatable redirect to prevent addon authors from accidentally breaking file-browser
|
||||
local fb_utils = { API_VERSION = g.API_VERSION }
|
||||
|
||||
fb_utils.list = {}
|
||||
fb_utils.coroutine = {}
|
||||
|
||||
--implements table.pack if on lua 5.1
|
||||
if not table.pack then
|
||||
table.unpack = unpack
|
||||
---@diagnostic disable-next-line: duplicate-set-field
|
||||
function table.pack(...)
|
||||
local t = {n = select("#", ...), ...}
|
||||
return t
|
||||
end
|
||||
end
|
||||
|
||||
-- returns the index of the given item in the table
|
||||
-- return -1 if item does not exist
|
||||
function fb_utils.list.indexOf(t, item, from_index)
|
||||
for i = from_index or 1, #t, 1 do
|
||||
if t[i] == item then return i end
|
||||
end
|
||||
return -1
|
||||
end
|
||||
|
||||
--returns whether or not the given table contains an entry that
|
||||
--causes the given function to evaluate to true
|
||||
function fb_utils.list.some(t, fn)
|
||||
for i, v in ipairs(t) do
|
||||
if fn(v, i, t) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Creates a new table populated with the results of
|
||||
-- calling a provided function on every element in t.
|
||||
function fb_utils.list.map(t, fn)
|
||||
local new_t = {}
|
||||
for i, v in ipairs(t) do
|
||||
new_t[i] = fn(v, i, t)
|
||||
end
|
||||
return new_t
|
||||
end
|
||||
|
||||
--prints an error message and a stack trace
|
||||
--accepts an error object and optionally a coroutine
|
||||
--can be passed directly to xpcall
|
||||
function fb_utils.traceback(errmsg, co)
|
||||
if co then
|
||||
msg.warn(debug.traceback(co))
|
||||
else
|
||||
msg.warn(debug.traceback("", 2))
|
||||
end
|
||||
msg.error(errmsg)
|
||||
end
|
||||
|
||||
--returns a table that stores the given table t as the __index in its metatable
|
||||
--creates a prototypally inherited table
|
||||
function fb_utils.redirect_table(t)
|
||||
return setmetatable({}, { __index = t })
|
||||
end
|
||||
|
||||
function fb_utils.set_prototype(t, proto)
|
||||
return setmetatable(t, { __index = proto })
|
||||
end
|
||||
|
||||
--prints an error if a coroutine returns an error
|
||||
--unlike the next function this one still returns the results of coroutine.resume()
|
||||
function fb_utils.coroutine.resume_catch(...)
|
||||
local returns = table.pack(coroutine.resume(...))
|
||||
if not returns[1] and returns[2] ~= g.ABORT_ERROR then
|
||||
fb_utils.traceback(returns[2], select(1, ...))
|
||||
end
|
||||
return table.unpack(returns, 1, returns.n)
|
||||
end
|
||||
|
||||
--resumes a coroutine and prints an error if it was not sucessful
|
||||
function fb_utils.coroutine.resume_err(...)
|
||||
local success, err = coroutine.resume(...)
|
||||
if not success and err ~= g.ABORT_ERROR then
|
||||
fb_utils.traceback(err, select(1, ...))
|
||||
end
|
||||
return success
|
||||
end
|
||||
|
||||
--in lua 5.1 there is only one return value which will be nil if run from the main thread
|
||||
--in lua 5.2 main will be true if running from the main thread
|
||||
function fb_utils.coroutine.assert(err)
|
||||
local co, main = coroutine.running()
|
||||
assert(not main and co, err or "error - function must be executed from within a coroutine")
|
||||
return co
|
||||
end
|
||||
|
||||
-- Creates a callback function to resume the current coroutine with the given time limit.
|
||||
-- If the time limit expires the coroutine will be resumed. The first return value will be true
|
||||
-- if the callback was resumed within the time limit and false otherwise.
|
||||
-- If time_limit is falsy then there will be no time limit and there will be no additional return value.
|
||||
function fb_utils.coroutine.callback(time_limit)
|
||||
local co = fb_utils.coroutine.assert("cannot create a coroutine callback for the main thread")
|
||||
local timer = time_limit and mp.add_timeout(time_limit, function ()
|
||||
msg.debug("time limit on callback expired")
|
||||
fb_utils.coroutine.resume_err(co, false)
|
||||
end)
|
||||
return function(...)
|
||||
if timer then
|
||||
if not timer:is_enabled() then return
|
||||
else timer:kill() end
|
||||
return fb_utils.coroutine.resume_err(co, true, ...)
|
||||
end
|
||||
return fb_utils.coroutine.resume_err(co, ...)
|
||||
end
|
||||
end
|
||||
|
||||
--puts the current coroutine to sleep for the given number of seconds
|
||||
function fb_utils.coroutine.sleep(n)
|
||||
mp.add_timeout(n, fb_utils.coroutine.callback())
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
|
||||
--Runs the given function in a coroutine, passing through any additional arguments.
|
||||
--Does not run the coroutine immediately, instead it ques the coroutine to run when the thread is next idle.
|
||||
--Returns the coroutine object so that the caller can act on it before it is run.
|
||||
function fb_utils.coroutine.queue(fn, ...)
|
||||
local co = coroutine.create(fn)
|
||||
local args = table.pack(...)
|
||||
mp.add_timeout(0, function() fb_utils.coroutine.resume_err(co, table.unpack(args, 1, args.n)) end)
|
||||
return co
|
||||
end
|
||||
|
||||
--runs the given function in a coroutine, passing through any additional arguments
|
||||
--this is for triggering an event in a coroutine
|
||||
function fb_utils.coroutine.run(fn, ...)
|
||||
local co = coroutine.create(fn)
|
||||
fb_utils.coroutine.resume_err(co, ...)
|
||||
end
|
||||
|
||||
--get the full path for the current file
|
||||
function fb_utils.get_full_path(item, dir)
|
||||
if item.path then return item.path end
|
||||
return (dir or g.state.directory)..item.name
|
||||
end
|
||||
|
||||
--gets the path for a new subdirectory, redirects if the path field is set
|
||||
--returns the new directory path and a boolean specifying if a redirect happened
|
||||
function fb_utils.get_new_directory(item, directory)
|
||||
if item.path and item.redirect ~= false then return item.path, true end
|
||||
if directory == "" then return item.name end
|
||||
if string.sub(directory, -1) == "/" then return directory..item.name end
|
||||
return directory.."/"..item.name
|
||||
end
|
||||
|
||||
--returns the file extension of the given file
|
||||
function fb_utils.get_extension(filename, def)
|
||||
return string.lower(filename):match("%.([^%./]+)$") or def
|
||||
end
|
||||
|
||||
--returns the protocol scheme of the given url, or nil if there is none
|
||||
function fb_utils.get_protocol(filename, def)
|
||||
return string.lower(filename):match("^(%a[%w+-.]*)://") or def
|
||||
end
|
||||
|
||||
--formats strings for ass handling
|
||||
--this function is based on a similar function from https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110
|
||||
function fb_utils.ass_escape(str, replace_newline)
|
||||
if replace_newline == true then replace_newline = "\\\239\187\191n" end
|
||||
|
||||
--escape the invalid single characters
|
||||
str = string.gsub(str, '[\\{}\n]', {
|
||||
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
|
||||
-- it isn't followed by a recognised character, so add a zero-width
|
||||
-- non-breaking space
|
||||
['\\'] = '\\\239\187\191',
|
||||
['{'] = '\\{',
|
||||
['}'] = '\\}',
|
||||
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
|
||||
-- consecutive newlines
|
||||
['\n'] = '\239\187\191\\N',
|
||||
})
|
||||
|
||||
-- Turn leading spaces into hard spaces to prevent ASS from stripping them
|
||||
str = str:gsub('\\N ', '\\N\\h')
|
||||
str = str:gsub('^ ', '\\h')
|
||||
|
||||
if replace_newline then
|
||||
str = str:gsub("\\N", replace_newline)
|
||||
end
|
||||
return str
|
||||
end
|
||||
|
||||
--escape lua pattern characters
|
||||
function fb_utils.pattern_escape(str)
|
||||
return string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1")
|
||||
end
|
||||
|
||||
--standardises filepaths across systems
|
||||
function fb_utils.fix_path(str, is_directory)
|
||||
if str == '' then return str end
|
||||
if o.normalise_backslash == 'yes' or (o.normalise_backslash == 'auto' and g.PLATFORM == 'windows') then
|
||||
str = string.gsub(str, [[\]],[[/]])
|
||||
end
|
||||
str = str:gsub([[/%./]], [[/]])
|
||||
if is_directory and str:sub(-1) ~= '/' then str = str..'/' end
|
||||
return str
|
||||
end
|
||||
|
||||
--wrapper for utils.join_path to handle protocols
|
||||
function fb_utils.join_path(working, relative)
|
||||
return fb_utils.get_protocol(relative) and relative or utils.join_path(working, relative)
|
||||
end
|
||||
|
||||
--converts the given path into an absolute path and normalises it using fb_utils.fix_path
|
||||
function fb_utils.absolute_path(path)
|
||||
local absolute_path = fb_utils.join_path(mp.get_property('working-directory', ''), path)
|
||||
return fb_utils.fix_path(absolute_path)
|
||||
end
|
||||
|
||||
--sorts the table lexicographically ignoring case and accounting for leading/non-leading zeroes
|
||||
--the number format functionality was proposed by github user twophyro, and was presumably taken
|
||||
--from here: http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
|
||||
function fb_utils.sort(t)
|
||||
local function padnum(n, d)
|
||||
return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
|
||||
or ("%03d%s"):format(#n, n)
|
||||
end
|
||||
|
||||
--appends the letter d or f to the start of the comparison to sort directories and folders as well
|
||||
local tuples = {}
|
||||
for i, f in ipairs(t) do
|
||||
tuples[i] = {f.type:sub(1, 1) .. (f.label or f.name):lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
|
||||
end
|
||||
table.sort(tuples, function(a, b)
|
||||
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
|
||||
end)
|
||||
for i, tuple in ipairs(tuples) do t[i] = tuple[2] end
|
||||
return t
|
||||
end
|
||||
|
||||
function fb_utils.valid_dir(dir)
|
||||
if o.filter_dot_dirs and string.sub(dir, 1, 1) == "." then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
function fb_utils.valid_file(file)
|
||||
if o.filter_dot_files and (string.sub(file, 1, 1) == ".") then return false end
|
||||
if o.filter_files and not g.extensions[ fb_utils.get_extension(file, "") ] then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
--returns whether or not the item can be parsed
|
||||
function fb_utils.parseable_item(item)
|
||||
return item.type == "dir" or g.parseable_extensions[fb_utils.get_extension(item.name, "")]
|
||||
end
|
||||
|
||||
-- Takes a directory string and resolves any directory mappings,
|
||||
-- returning the resolved directory.
|
||||
function fb_utils.resolve_directory_mapping(path)
|
||||
if not path then return path end
|
||||
|
||||
for mapping, target in pairs(g.directory_mappings) do
|
||||
local start, finish = string.find(path, mapping)
|
||||
if start then
|
||||
msg.debug('mapping', mapping, 'found for', path, 'changing to', target)
|
||||
|
||||
-- if the mapping is an exact match then return the target as is
|
||||
if finish == #path then return target end
|
||||
|
||||
-- else make sure the path is correctly formatted
|
||||
target = fb_utils.fix_path(target, true)
|
||||
return string.gsub(path, mapping, target)
|
||||
end
|
||||
end
|
||||
|
||||
return path
|
||||
end
|
||||
|
||||
--removes items and folders from the list
|
||||
--this is for addons which can't filter things during their normal processing
|
||||
function fb_utils.filter(t)
|
||||
local max = #t
|
||||
local top = 1
|
||||
for i = 1, max do
|
||||
local temp = t[i]
|
||||
t[i] = nil
|
||||
|
||||
if ( temp.type == "dir" and fb_utils.valid_dir(temp.label or temp.name) ) or
|
||||
( temp.type == "file" and fb_utils.valid_file(temp.label or temp.name) )
|
||||
then
|
||||
t[top] = temp
|
||||
top = top+1
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
--returns a string iterator that uses the root separators
|
||||
function fb_utils.iterate_opt(str)
|
||||
return string.gmatch(str, "([^"..fb_utils.pattern_escape(o.root_separators).."]+)")
|
||||
end
|
||||
|
||||
--sorts a table into an array of selected items in the correct order
|
||||
--if a predicate function is passed, then the item will only be added to
|
||||
--the table if the function returns true
|
||||
function fb_utils.sort_keys(t, include_item)
|
||||
local keys = {}
|
||||
for k in pairs(t) do
|
||||
local item = g.state.list[k]
|
||||
if not include_item or include_item(item) then
|
||||
item.index = k
|
||||
keys[#keys+1] = item
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(keys, function(a,b) return a.index < b.index end)
|
||||
return keys
|
||||
end
|
||||
|
||||
--Uses a loop to get the length of an array. The `#` operator is undefined if there
|
||||
--are gaps in the array, this ensures there are none as expected by the mpv node function.
|
||||
local function get_length(t)
|
||||
local i = 1
|
||||
while t[i] do i = i+1 end
|
||||
return i - 1
|
||||
end
|
||||
|
||||
--recursively removes elements of the table which would cause
|
||||
--utils.format_json to throw an error
|
||||
local function json_safe_recursive(t)
|
||||
if type(t) ~= "table" then return t end
|
||||
|
||||
local array_length = get_length(t)
|
||||
local isarray = array_length > 0
|
||||
|
||||
for key, value in pairs(t) do
|
||||
local ktype = type(key)
|
||||
local vtype = type(value)
|
||||
|
||||
if vtype ~= "userdata" and vtype ~= "function" and vtype ~= "thread"
|
||||
and (( isarray and ktype == "number" and key <= array_length)
|
||||
or (not isarray and ktype == "string"))
|
||||
then
|
||||
t[key] = json_safe_recursive(t[key])
|
||||
elseif key then
|
||||
t[key] = nil
|
||||
if isarray then array_length = get_length(t) end
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
--formats a table into a json string but ensures there are no invalid datatypes inside the table first
|
||||
function fb_utils.format_json_safe(t)
|
||||
--operate on a copy of the table to prevent any data loss in the original table
|
||||
t = json_safe_recursive(fb_utils.copy_table(t))
|
||||
local success, result, err = pcall(utils.format_json, t)
|
||||
if success then return result, err
|
||||
else return nil, result end
|
||||
end
|
||||
|
||||
--evaluates and runs the given string in both Lua 5.1 and 5.2
|
||||
--the name argument is used for error reporting
|
||||
--provides the mpv modules and the fb module to the string
|
||||
function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults)
|
||||
local env
|
||||
if env_defaults ~= false then
|
||||
env = fb_utils.redirect_table(_G)
|
||||
env.mp = fb_utils.redirect_table(mp)
|
||||
env.msg = fb_utils.redirect_table(msg)
|
||||
env.utils = fb_utils.redirect_table(utils)
|
||||
env.fb = fb_utils.redirect_table(fb_utils)
|
||||
env.input = input_loaded and fb_utils.redirect_table(input)
|
||||
env.user_input = user_input_loaded and fb_utils.redirect_table(user_input)
|
||||
env = fb_utils.set_prototype(custom_env or {}, env)
|
||||
else
|
||||
env = custom_env or {}
|
||||
end
|
||||
|
||||
local chunk, err
|
||||
if setfenv then
|
||||
chunk, err = loadstring(str, chunkname)
|
||||
if chunk then setfenv(chunk, env) end
|
||||
else
|
||||
chunk, err = load(str, chunkname, 't', env)
|
||||
end
|
||||
if not chunk then
|
||||
msg.warn('failed to load string:', str)
|
||||
msg.error(err)
|
||||
chunk = function() return nil end
|
||||
end
|
||||
|
||||
return chunk()
|
||||
end
|
||||
|
||||
--copies a table without leaving any references to the original
|
||||
--uses a structured clone algorithm to maintain cyclic references
|
||||
local function copy_table_recursive(t, references, depth)
|
||||
if type(t) ~= "table" or depth == 0 then return t end
|
||||
if references[t] then return references[t] end
|
||||
|
||||
local copy = setmetatable({}, { __original = t })
|
||||
references[t] = copy
|
||||
|
||||
for key, value in pairs(t) do
|
||||
key = copy_table_recursive(key, references, depth - 1)
|
||||
copy[key] = copy_table_recursive(value, references, depth - 1)
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
--a wrapper around copy_table to provide the reference table
|
||||
function fb_utils.copy_table(t, depth)
|
||||
--this is to handle cyclic table references
|
||||
return copy_table_recursive(t, {}, depth or math.huge)
|
||||
end
|
||||
|
||||
--functions to replace custom-keybind codes
|
||||
fb_utils.code_fns = {
|
||||
["%"] = "%",
|
||||
|
||||
f = function(item, s) return item and fb_utils.get_full_path(item, s.directory) or "" end,
|
||||
n = function(item, s) return item and (item.label or item.name) or "" end,
|
||||
i = function(item, s)
|
||||
local i = fb_utils.list.indexOf(s.list, item)
|
||||
return i ~= -1 and ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i) or 0
|
||||
end,
|
||||
j = function (item, s)
|
||||
return fb_utils.list.indexOf(s.list, item) ~= -1 and math.abs(fb_utils.list.indexOf( fb_utils.sort_keys(s.selection) , item)) or 0
|
||||
end,
|
||||
x = function(_, s) return #s.list or 0 end,
|
||||
p = function(_, s) return s.directory or "" end,
|
||||
q = function(_, s) return s.directory == '' and 'ROOT' or s.directory_label or s.directory or "" end,
|
||||
d = function(_, s) return (s.directory_label or s.directory):match("([^/]+)/?$") or "" end,
|
||||
r = function(_, s) return s.parser.keybind_name or s.parser.name or "" end,
|
||||
}
|
||||
|
||||
-- programatically creates a pattern that matches any key code
|
||||
-- this will result in some duplicates but that shouldn't really matter
|
||||
function fb_utils.get_code_pattern(codes)
|
||||
local CUSTOM_KEYBIND_CODES = ""
|
||||
for key in pairs(codes) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end
|
||||
for key in pairs((getmetatable(codes) or {}).__index or {}) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end
|
||||
return('%%%%([%s])'):format(fb_utils.pattern_escape(CUSTOM_KEYBIND_CODES))
|
||||
end
|
||||
|
||||
-- substitutes codes in the given string for other substrings
|
||||
-- overrides is a map of characters->strings|functions that determines the replacement string is
|
||||
-- item and state are values passed to functions in the map
|
||||
-- modifier_fn is given the replacement substrings before they are placed in the main string (the return value is the new replacement string)
|
||||
function fb_utils.substitute_codes(str, overrides, item, state, modifier_fn)
|
||||
local replacers = overrides and setmetatable(fb_utils.copy_table(overrides), {__index = fb_utils.code_fns}) or fb_utils.code_fns
|
||||
item = item or g.state.list[g.state.selected]
|
||||
state = state or g.state
|
||||
|
||||
return (string.gsub(str, fb_utils.get_code_pattern(replacers), function(code)
|
||||
local result
|
||||
|
||||
if type(replacers[code]) == "string" then
|
||||
result = replacers[code]
|
||||
--encapsulates the string if using an uppercase code
|
||||
elseif not replacers[code] then
|
||||
local lower_fn = replacers[code:lower()]
|
||||
if not lower_fn then return end
|
||||
result = string.format("%q", lower_fn(item, state))
|
||||
else
|
||||
result = replacers[code](item, state)
|
||||
end
|
||||
|
||||
if modifier_fn then return modifier_fn(result) end
|
||||
return result
|
||||
end))
|
||||
end
|
||||
|
||||
|
||||
return fb_utils
|
||||
Reference in New Issue
Block a user