utils.lua (22864B)
1 -------------------------------------------------------------------------------------------------------- 2 -----------------------------------------Utility Functions---------------------------------------------- 3 ---------------------------------------Part of the addon API-------------------------------------------- 4 -------------------------------------------------------------------------------------------------------- 5 6 local mp = require 'mp' 7 local msg = require 'mp.msg' 8 local utils = require 'mp.utils' 9 10 local o = require 'modules.options' 11 local g = require 'modules.globals' 12 13 local input_loaded, input = pcall(require, 'mp.input') 14 local user_input_loaded, user_input = pcall(require, 'user-input-module') 15 16 --creates a table for the API functions 17 --adds one metatable redirect to prevent addon authors from accidentally breaking file-browser 18 ---@class fb_utils 19 local fb_utils = { API_VERSION = g.API_VERSION } 20 21 fb_utils.list = {} 22 fb_utils.coroutine = {} 23 24 --implements table.pack if on lua 5.1 25 if not table.pack then 26 table.unpack = unpack ---@diagnostic disable-line deprecated 27 ---@diagnostic disable-next-line: duplicate-set-field 28 function table.pack(...) 29 local t = {n = select("#", ...), ...} 30 return t 31 end 32 end 33 34 ---Returns the index of the given item in the table. 35 ---Return -1 if item does not exist. 36 ---@generic T 37 ---@param t T[] 38 ---@param item T 39 ---@param from_index? number 40 ---@return integer 41 function fb_utils.list.indexOf(t, item, from_index) 42 for i = from_index or 1, #t, 1 do 43 if t[i] == item then return i end 44 end 45 return -1 46 end 47 48 ---Returns whether or not the given table contains an entry that 49 ---causes the given function to evaluate to true. 50 ---@generic T 51 ---@param t T[] 52 ---@param fn fun(v: T, i: number, t: T[]): boolean 53 ---@return boolean 54 function fb_utils.list.some(t, fn) 55 for i, v in ipairs(t --[=[@as any[]]=]) do 56 if fn(v, i, t) then return true end 57 end 58 return false 59 end 60 61 ---Creates a new table populated with the results of 62 ---calling a provided function on every element in t. 63 ---@generic T 64 ---@generic R 65 ---@param t T[] 66 ---@param fn fun(v: T, i: number, t: T[]): R 67 ---@return R[] 68 function fb_utils.list.map(t, fn) 69 local new_t = {} 70 for i, v in ipairs(t --[=[@as any[]]=]) do 71 new_t[i] = fn(v, i, t) ---@diagnostic disable-line no-unknown 72 end 73 return new_t 74 end 75 76 ---Prints an error message and a stack trace. 77 ---Can be passed directly to xpcall. 78 ---@param errmsg string 79 ---@param co? thread A coroutine to grab the stack trace from. 80 function fb_utils.traceback(errmsg, co) 81 if co then 82 msg.warn(debug.traceback(co)) 83 else 84 msg.warn(debug.traceback("", 2)) 85 end 86 msg.error(errmsg) 87 end 88 89 ---Returns a table that stores the given table t as the __index in its metatable. 90 ---Creates a prototypally inherited table. 91 ---@generic T: table 92 ---@param t T 93 ---@return T 94 function fb_utils.redirect_table(t) 95 return setmetatable({}, { __index = t }) 96 end 97 98 ---Sets the given table `proto` as the `__index` field in table `t`s metatable. 99 ---@generic T: table 100 ---@param t T 101 ---@param proto table 102 ---@return T 103 function fb_utils.set_prototype(t, proto) 104 return setmetatable(t, { __index = proto }) 105 end 106 107 ---Prints an error if a coroutine returns an error. 108 ---Unlike coroutine.resume_err this still returns the results of coroutine.resume(). 109 ---@param ... any 110 ---@return boolean 111 ---@return ... 112 function fb_utils.coroutine.resume_catch(...) 113 local returns = table.pack(coroutine.resume(...)) 114 if not returns[1] and returns[2] ~= g.ABORT_ERROR then 115 fb_utils.traceback(returns[2], select(1, ...)) 116 end 117 return table.unpack(returns, 1, returns.n) 118 end 119 120 ---Resumes a coroutine and prints an error if it was not sucessful. 121 ---@param ... any 122 ---@return boolean 123 function fb_utils.coroutine.resume_err(...) 124 local success, err = coroutine.resume(...) 125 if not success and err ~= g.ABORT_ERROR then 126 fb_utils.traceback(err, select(1, ...)) 127 end 128 return success 129 end 130 131 ---Throws an error if not run from within a coroutine. 132 ---In lua 5.1 there is only one return value which will be nil if run from the main thread. 133 ---In lua 5.2 main will be true if running from the main thread. 134 ---@param err any 135 ---@return thread 136 function fb_utils.coroutine.assert(err) 137 local co, main = coroutine.running() 138 assert(not main and co, err or "error - function must be executed from within a coroutine") 139 return co 140 end 141 142 ---Creates a callback function to resume the current coroutine with the given time limit. 143 ---If the time limit expires the coroutine will be resumed. The first return value will be true 144 ---if the callback was resumed within the time limit and false otherwise. 145 ---If time_limit is falsy then there will be no time limit and there will be no additional return value. 146 ---@param time_limit? number seconds 147 ---@return fun(...) 148 function fb_utils.coroutine.callback(time_limit) 149 local co = fb_utils.coroutine.assert("cannot create a coroutine callback for the main thread") 150 local timer = time_limit and mp.add_timeout(time_limit, function () 151 msg.debug("time limit on callback expired") 152 fb_utils.coroutine.resume_err(co, false) 153 end) 154 155 local function fn(...) 156 if timer then 157 if not timer:is_enabled() then return 158 else timer:kill() end 159 return fb_utils.coroutine.resume_err(co, true, ...) 160 end 161 return fb_utils.coroutine.resume_err(co, ...) 162 end 163 return fn 164 end 165 166 ---Puts the current coroutine to sleep for the given number of seconds. 167 ---@async 168 ---@param n number 169 ---@return nil 170 function fb_utils.coroutine.sleep(n) 171 mp.add_timeout(n, fb_utils.coroutine.callback()) 172 coroutine.yield() 173 end 174 175 ---Runs the given function in a coroutine, passing through any additional arguments. 176 ---Does not run the coroutine immediately, instead it queues the coroutine to run when the thread is next idle. 177 ---Returns the coroutine object so that the caller can act on it before it is run. 178 ---@param fn async fun() 179 ---@param ... any 180 ---@return thread 181 function fb_utils.coroutine.queue(fn, ...) 182 local co = coroutine.create(fn) 183 local args = table.pack(...) 184 mp.add_timeout(0, function() fb_utils.coroutine.resume_err(co, table.unpack(args, 1, args.n)) end) 185 return co 186 end 187 188 ---Runs the given function in a coroutine, passing through any additional arguments. 189 ---This is for triggering an event in a coroutine. 190 ---@param fn async fun() 191 ---@param ... any 192 function fb_utils.coroutine.run(fn, ...) 193 local co = coroutine.create(fn) 194 fb_utils.coroutine.resume_err(co, ...) 195 end 196 197 ---Get the full path for the current file. 198 ---@param item Item 199 ---@param dir? string 200 ---@return string 201 function fb_utils.get_full_path(item, dir) 202 if item.path then return item.path end 203 return (dir or g.state.directory)..item.name 204 end 205 206 ---Gets the path for a new subdirectory, redirects if the path field is set. 207 ---Returns the new directory path and a boolean specifying if a redirect happened. 208 ---@param item Item 209 ---@param directory string 210 ---@return string new_directory 211 ---@return boolean? redirected `true` if the path was redirected 212 function fb_utils.get_new_directory(item, directory) 213 if item.path and item.redirect ~= false then return item.path, true end 214 if directory == "" then return item.name end 215 if string.sub(directory, -1) == "/" then return directory..item.name end 216 return directory.."/"..item.name 217 end 218 219 ---Returns the file extension of the given file, or def if there is none. 220 ---@generic T 221 ---@param filename string 222 ---@param def? T 223 ---@return string|T 224 ---@overload fun(filename: string): string|nil 225 function fb_utils.get_extension(filename, def) 226 return string.lower(filename):match("%.([^%./]+)$") or def 227 end 228 229 ---Returns the protocol scheme of the given url, or def if there is none. 230 ---@generic T 231 ---@param filename string 232 ---@param def T 233 ---@return string|T 234 ---@overload fun(filename: string): string|nil 235 function fb_utils.get_protocol(filename, def) 236 return string.lower(filename):match("^(%a[%w+-.]*)://") or def 237 end 238 239 ---Formats strings for ass handling. 240 ---This function is based on a similar function from 241 ---https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110. 242 ---@param str string 243 ---@param replace_newline? true|string 244 ---@return string 245 function fb_utils.ass_escape(str, replace_newline) 246 if replace_newline == true then replace_newline = "\\\239\187\191n" end 247 248 --escape the invalid single characters 249 str = string.gsub(str, '[\\{}\n]', { 250 -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if 251 -- it isn't followed by a recognised character, so add a zero-width 252 -- non-breaking space 253 ['\\'] = '\\\239\187\191', 254 ['{'] = '\\{', 255 ['}'] = '\\}', 256 -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of 257 -- consecutive newlines 258 ['\n'] = '\239\187\191\\N', 259 }) 260 261 -- Turn leading spaces into hard spaces to prevent ASS from stripping them 262 str = str:gsub('\\N ', '\\N\\h') 263 str = str:gsub('^ ', '\\h') 264 265 if replace_newline then 266 str = string.gsub(str, "\\N", replace_newline) 267 end 268 return str 269 end 270 271 ---Escape lua pattern characters. 272 ---@param str string 273 ---@return string 274 function fb_utils.pattern_escape(str) 275 return (string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1")) 276 end 277 278 ---Standardises filepaths across systems. 279 ---@param str string 280 ---@param is_directory? boolean 281 ---@return string 282 function fb_utils.fix_path(str, is_directory) 283 if str == '' then return str end 284 if o.normalise_backslash == 'yes' or (o.normalise_backslash == 'auto' and g.PLATFORM == 'windows') then 285 str = string.gsub(str, [[\]],[[/]]) 286 end 287 str = str:gsub([[/%./]], [[/]]) 288 if is_directory and str:sub(-1) ~= '/' then str = str..'/' end 289 return str 290 end 291 292 ---Wrapper for mp.utils.join_path to handle protocols. 293 ---@param working string 294 ---@param relative string 295 ---@return string 296 function fb_utils.join_path(working, relative) 297 return fb_utils.get_protocol(relative) and relative or utils.join_path(working, relative) 298 end 299 300 ---Converts the given path into an absolute path and normalises it using fb_utils.fix_path. 301 ---@param path string 302 ---@return string 303 function fb_utils.absolute_path(path) 304 local absolute_path = fb_utils.join_path(mp.get_property('working-directory', ''), path) 305 return fb_utils.fix_path(absolute_path) 306 end 307 308 ---Sorts the table lexicographically ignoring case and accounting for leading/non-leading zeroes. 309 ---The number format functionality was proposed by github user twophyro, and was presumably taken 310 ---from here: http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua. 311 ---@param t List 312 ---@return List 313 function fb_utils.sort(t) 314 local function padnum(n, d) 315 return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d)) 316 or ("%03d%s"):format(#n, n) 317 end 318 319 --appends the letter d or f to the start of the comparison to sort directories and folders as well 320 ---@type [string,Item][] 321 local tuples = {} 322 for i, f in ipairs(t) do 323 tuples[i] = {f.type:sub(1, 1) .. (f.label or f.name):lower():gsub("0*(%d+)%.?(%d*)", padnum), f} 324 end 325 table.sort(tuples, function(a, b) 326 -- pretty sure that `#b[2] < #a[2]` does not do anything as they are both Item tables and not strings or arrays 327 return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1] 328 end) 329 for i, tuple in ipairs(tuples) do t[i] = tuple[2] end 330 return t 331 end 332 333 ---@param dir string 334 ---@return boolean 335 function fb_utils.valid_dir(dir) 336 if o.filter_dot_dirs == 'yes' or o.filter_dot_dirs == 'auto' and g.PLATFORM ~= 'windows' then 337 return string.sub(dir, 1, 1) ~= "." 338 end 339 return true 340 end 341 342 ---@param file string 343 ---@return boolean 344 function fb_utils.valid_file(file) 345 if o.filter_dot_files == 'yes' or o.filter_dot_files == 'auto' and g.PLATFORM ~= 'windows' then 346 if string.sub(file, 1, 1) == "." then return false end 347 end 348 if o.filter_files and not g.extensions[ fb_utils.get_extension(file, "") ] then return false end 349 return true 350 end 351 352 ---Returns whether or not the item can be parsed. 353 ---@param item Item 354 ---@return boolean 355 function fb_utils.parseable_item(item) 356 return item.type == "dir" or g.parseable_extensions[fb_utils.get_extension(item.name, "")] 357 end 358 359 ---Takes a directory string and resolves any directory mappings, 360 ---returning the resolved directory. 361 ---@param path string 362 ---@return string 363 function fb_utils.resolve_directory_mapping(path) 364 if not path then return path end 365 366 for mapping, target in pairs(g.directory_mappings) do 367 local start, finish = string.find(path, mapping) 368 if start then 369 msg.debug('mapping', mapping, 'found for', path, 'changing to', target) 370 371 -- if the mapping is an exact match then return the target as is 372 if finish == #path then return target end 373 374 -- else make sure the path is correctly formatted 375 target = fb_utils.fix_path(target, true) 376 return (string.gsub(path, mapping, target)) 377 end 378 end 379 380 return path 381 end 382 383 ---Removes items and folders from the list that fail the configured filters. 384 ---@param t List 385 ---@return List 386 function fb_utils.filter(t) 387 local max = #t 388 local top = 1 389 for i = 1, max do 390 local temp = t[i] 391 t[i] = nil 392 393 if ( temp.type == "dir" and fb_utils.valid_dir(temp.label or temp.name) ) or 394 ( temp.type == "file" and fb_utils.valid_file(temp.label or temp.name) ) 395 then 396 t[top] = temp 397 top = top+1 398 end 399 end 400 return t 401 end 402 403 ---Returns a string iterator that uses the root separators. 404 ---@param str any 405 ---@param separators? string Override the root separators. 406 ---@return fun():(string, ...) 407 function fb_utils.iterate_opt(str, separators) 408 return string.gmatch(str, "([^"..fb_utils.pattern_escape(separators or o.root_separators).."]+)") 409 end 410 411 ---Sorts a table into an array of selected items in the correct order. 412 ---If a predicate function is passed, then the item will only be added to 413 ---the table if the function returns true. 414 ---@param t Set<number> 415 ---@param include_item? fun(item: Item): boolean 416 ---@return Item[] 417 function fb_utils.sort_keys(t, include_item) 418 ---@class Ref 419 ---@field item Item 420 ---@field index number 421 422 ---@type Ref[] 423 local keys = {} 424 for k in pairs(t) do 425 local item = g.state.list[k] 426 if not include_item or include_item(item) then 427 keys[#keys+1] = { 428 item = item, 429 index = k, 430 } 431 end 432 end 433 434 table.sort(keys, function(a,b) return a.index < b.index end) 435 return fb_utils.list.map(keys, function(ref) return ref.item end) 436 end 437 438 ---Uses a loop to get the length of an array. The `#` operator is undefined if there 439 ---are gaps in the array, this ensures there are none as expected by the mpv node function. 440 ---@param t any[] 441 ---@return integer 442 local function get_length(t) 443 local i = 1 444 while t[i] do i = i+1 end 445 return i - 1 446 end 447 448 ---Recursively removes elements of the table which would cause 449 ---utils.format_json to throw an error. 450 ---@generic T 451 ---@param t T 452 ---@return T 453 local function json_safe_recursive(t) 454 if type(t) ~= "table" then return t end 455 456 local array_length = get_length(t) 457 local isarray = array_length > 0 458 459 for key, value in pairs(t --[[@as table<any,any>]]) do 460 local ktype = type(key) 461 local vtype = type(value) 462 463 if vtype ~= "userdata" and vtype ~= "function" and vtype ~= "thread" 464 and (( isarray and ktype == "number" and key <= array_length) 465 or (not isarray and ktype == "string")) 466 then 467 ---@diagnostic disable-next-line no-unknown 468 t[key] = json_safe_recursive(t[key]) 469 elseif key then 470 ---@diagnostic disable-next-line no-unknown 471 t[key] = nil 472 if isarray then array_length = get_length(t) end 473 end 474 end 475 return t 476 end 477 478 ---Formats a table into a json string but ensures there are no invalid datatypes inside the table first. 479 ---@param t any 480 ---@return string|nil 481 ---@return string|nil err 482 function fb_utils.format_json_safe(t) 483 --operate on a copy of the table to prevent any data loss in the original table 484 t = json_safe_recursive(fb_utils.copy_table(t)) 485 local success, result, err = pcall(utils.format_json, t) 486 if success then return result, err 487 else return nil, result end 488 end 489 490 ---Evaluates and runs the given string in both Lua 5.1 and 5.2. 491 ---Provides the mpv modules and the fb module to the string. 492 ---@param str string 493 ---@param chunkname? string Used for error reporting. 494 ---@param custom_env? table A custom environment that shadows the default environment. 495 ---@param env_defaults? boolean Load lua defaults in environment, as well as mpv and file-browser modules. Defaults to `true`. 496 ---@return unknown 497 function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults) 498 ---@type table 499 local env 500 if env_defaults ~= false then 501 ---@type table 502 env = fb_utils.redirect_table(_G) 503 env.mp = fb_utils.redirect_table(mp) 504 env.msg = fb_utils.redirect_table(msg) 505 env.utils = fb_utils.redirect_table(utils) 506 env.fb = fb_utils.redirect_table(require 'file-browser') 507 env.input = input_loaded and fb_utils.redirect_table(input) 508 env.user_input = user_input_loaded and fb_utils.redirect_table(user_input) 509 env = fb_utils.set_prototype(custom_env or {}, env) 510 else 511 env = custom_env or {} 512 end 513 514 ---@type function, any 515 local chunk, err 516 if setfenv then ---@diagnostic disable-line deprecated 517 chunk, err = loadstring(str, chunkname) ---@diagnostic disable-line deprecated 518 if chunk then setfenv(chunk, env) end ---@diagnostic disable-line deprecated 519 else 520 chunk, err = load(str, chunkname, 't', env) ---@diagnostic disable-line redundant-parameter 521 end 522 if not chunk then 523 msg.warn('failed to load string:', str) 524 msg.error(err) 525 chunk = function() return nil end 526 end 527 528 return chunk() 529 end 530 531 ---Copies a table without leaving any references to the original. 532 ---Uses a structured clone algorithm to maintain cyclic references. 533 ---@generic T 534 ---@param t T 535 ---@param references table<table,table> 536 ---@param depth number 537 ---@return T 538 local function copy_table_recursive(t, references, depth) 539 if type(t) ~= "table" or depth == 0 then return t end 540 if references[t] then return references[t] end 541 542 local copy = setmetatable({}, { __original = t }) 543 references[t] = copy 544 545 for key, value in pairs(t --[[@as table<any,any>]]) do 546 key = copy_table_recursive(key, references, depth - 1) 547 copy[key] = copy_table_recursive(value, references, depth - 1) ---@diagnostic disable-line no-unknown 548 end 549 return copy 550 end 551 552 ---A wrapper around copy_table to provide the reference table. 553 ---@generic T 554 ---@param t T 555 ---@param depth? number 556 ---@return T 557 function fb_utils.copy_table(t, depth) 558 --this is to handle cyclic table references 559 return copy_table_recursive(t, {}, depth or math.huge) 560 end 561 562 ---@alias Replacer fun(item: Item, s: State): (string|number|nil) 563 ---@alias ReplacerTable table<string,Replacer> 564 565 ---functions to replace custom-keybind codes 566 ---@type ReplacerTable 567 fb_utils.code_fns = { 568 ["%"] = function() return "%" end, 569 570 f = function(item, s) return item and fb_utils.get_full_path(item, s.directory) or "" end, 571 n = function(item, s) return item and (item.label or item.name) or "" end, 572 i = function(item, s) 573 local i = fb_utils.list.indexOf(s.list, item) 574 if #s.list == 0 then return 0 end 575 return ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i ~= -1 and i or 0) ---@diagnostic disable-line deprecated 576 end, 577 j = function (item, s) 578 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 579 end, 580 x = function(_, s) return #s.list or 0 end, 581 p = function(_, s) return s.directory or "" end, 582 q = function(_, s) return s.directory == '' and 'ROOT' or s.directory_label or s.directory or "" end, 583 d = function(_, s) return (s.directory_label or s.directory):match("([^/]+)/?$") or "" end, 584 r = function(_, s) return s.parser.keybind_name or s.parser.name or "" end, 585 } 586 587 ---Programatically creates a pattern that matches any key code. 588 ---This will result in some duplicates but that shouldn't really matter. 589 ---@param codes ReplacerTable 590 ---@return string 591 function fb_utils.get_code_pattern(codes) 592 ---@type string 593 local CUSTOM_KEYBIND_CODES = "" 594 for key in pairs(codes) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end 595 for key in pairs((getmetatable(codes) or {}).__index or {} --[[@as ReplacerTable]]) do 596 ---@type string 597 CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() 598 end 599 return('%%%%([%s])'):format(fb_utils.pattern_escape(CUSTOM_KEYBIND_CODES)) 600 end 601 602 ---Substitutes codes in the given string for other substrings. 603 ---@param str string 604 ---@param overrides? ReplacerTable Replacer functions for additional characters to match to after `%` characters. 605 ---@param item? Item Uses the currently selected item if nil. 606 ---@param state? State Uses the global state if nil. 607 ---@param modifier_fn? fun(new_str: string, code: string): string given the replacement substrings before they are placed in the main string 608 --- (the return value is the new replacement string). 609 ---@return string 610 function fb_utils.substitute_codes(str, overrides, item, state, modifier_fn) 611 local replacers = overrides and setmetatable(fb_utils.copy_table(overrides), {__index = fb_utils.code_fns}) or fb_utils.code_fns 612 item = item or g.state.list[g.state.selected] 613 state = state or g.state 614 615 return (string.gsub(str, fb_utils.get_code_pattern(replacers), function(code) 616 ---@type string|number|nil 617 local result 618 local replacer = replacers[code] 619 620 if type(replacer) == "string" then 621 result = replacer 622 --encapsulates the string if using an uppercase code 623 elseif not replacer then 624 local lower_fn = replacers[code:lower()] 625 if not lower_fn then return end 626 result = string.format("%q", lower_fn(item, state)) 627 else 628 result = replacer(item, state) 629 end 630 631 if result and modifier_fn then return modifier_fn(tostring(result), code) end 632 return result 633 end)) 634 end 635 636 637 return fb_utils