1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
|
--------------------------------------------------------------------------------------------------------
-----------------------------------------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
---@class fb_utils
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-line deprecated
---@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.
---@generic T
---@param t T[]
---@param item T
---@param from_index? number
---@return integer
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.
---@generic T
---@param t T[]
---@param fn fun(v: T, i: number, t: T[]): boolean
---@return boolean
function fb_utils.list.some(t, fn)
for i, v in ipairs(t --[=[@as any[]]=]) 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.
---@generic T
---@generic R
---@param t T[]
---@param fn fun(v: T, i: number, t: T[]): R
---@return R[]
function fb_utils.list.map(t, fn)
local new_t = {}
for i, v in ipairs(t --[=[@as any[]]=]) do
new_t[i] = fn(v, i, t) ---@diagnostic disable-line no-unknown
end
return new_t
end
---Prints an error message and a stack trace.
---Can be passed directly to xpcall.
---@param errmsg string
---@param co? thread A coroutine to grab the stack trace from.
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.
---@generic T: table
---@param t T
---@return T
function fb_utils.redirect_table(t)
return setmetatable({}, { __index = t })
end
---Sets the given table `proto` as the `__index` field in table `t`s metatable.
---@generic T: table
---@param t T
---@param proto table
---@return T
function fb_utils.set_prototype(t, proto)
return setmetatable(t, { __index = proto })
end
---Prints an error if a coroutine returns an error.
---Unlike coroutine.resume_err this still returns the results of coroutine.resume().
---@param ... any
---@return boolean
---@return ...
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.
---@param ... any
---@return boolean
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
---Throws an error if not run from within a coroutine.
---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.
---@param err any
---@return 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.
---@param time_limit? number seconds
---@return fun(...)
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)
local function fn(...)
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
return fn
end
---Puts the current coroutine to sleep for the given number of seconds.
---@async
---@param n number
---@return nil
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 queues 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.
---@param fn async fun()
---@param ... any
---@return thread
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.
---@param fn async fun()
---@param ... any
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.
---@param item Item
---@param dir? string
---@return string
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.
---@param item Item
---@param directory string
---@return string new_directory
---@return boolean? redirected `true` if the path was redirected
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, or def if there is none.
---@generic T
---@param filename string
---@param def? T
---@return string|T
---@overload fun(filename: string): string|nil
function fb_utils.get_extension(filename, def)
return string.lower(filename):match("%.([^%./]+)$") or def
end
---Returns the protocol scheme of the given url, or def if there is none.
---@generic T
---@param filename string
---@param def T
---@return string|T
---@overload fun(filename: string): string|nil
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.
---@param str string
---@param replace_newline? true|string
---@return string
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 = string.gsub(str, "\\N", replace_newline)
end
return str
end
---Escape lua pattern characters.
---@param str string
---@return string
function fb_utils.pattern_escape(str)
return (string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1"))
end
---Standardises filepaths across systems.
---@param str string
---@param is_directory? boolean
---@return string
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 mp.utils.join_path to handle protocols.
---@param working string
---@param relative string
---@return string
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.
---@param path string
---@return string
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.
---@param t List
---@return List
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
---@type [string,Item][]
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)
-- pretty sure that `#b[2] < #a[2]` does not do anything as they are both Item tables and not strings or arrays
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
---@param dir string
---@return boolean
function fb_utils.valid_dir(dir)
if o.filter_dot_dirs == 'yes' or o.filter_dot_dirs == 'auto' and g.PLATFORM ~= 'windows' then
return string.sub(dir, 1, 1) ~= "."
end
return true
end
---@param file string
---@return boolean
function fb_utils.valid_file(file)
if o.filter_dot_files == 'yes' or o.filter_dot_files == 'auto' and g.PLATFORM ~= 'windows' then
if string.sub(file, 1, 1) == "." then return false end
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.
---@param item Item
---@return boolean
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.
---@param path string
---@return string
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 that fail the configured filters.
---@param t List
---@return List
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.
---@param str any
---@param separators? string Override the root separators.
---@return fun():(string, ...)
function fb_utils.iterate_opt(str, separators)
return string.gmatch(str, "([^"..fb_utils.pattern_escape(separators or 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.
---@param t Set<number>
---@param include_item? fun(item: Item): boolean
---@return Item[]
function fb_utils.sort_keys(t, include_item)
---@class Ref
---@field item Item
---@field index number
---@type Ref[]
local keys = {}
for k in pairs(t) do
local item = g.state.list[k]
if not include_item or include_item(item) then
keys[#keys+1] = {
item = item,
index = k,
}
end
end
table.sort(keys, function(a,b) return a.index < b.index end)
return fb_utils.list.map(keys, function(ref) return ref.item end)
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.
---@param t any[]
---@return integer
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.
---@generic T
---@param t T
---@return T
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 --[[@as table<any,any>]]) 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
---@diagnostic disable-next-line no-unknown
t[key] = json_safe_recursive(t[key])
elseif key then
---@diagnostic disable-next-line no-unknown
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.
---@param t any
---@return string|nil
---@return string|nil err
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.
---Provides the mpv modules and the fb module to the string.
---@param str string
---@param chunkname? string Used for error reporting.
---@param custom_env? table A custom environment that shadows the default environment.
---@param env_defaults? boolean Load lua defaults in environment, as well as mpv and file-browser modules. Defaults to `true`.
---@return unknown
function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults)
---@type table
local env
if env_defaults ~= false then
---@type table
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(require 'file-browser')
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
---@type function, any
local chunk, err
if setfenv then ---@diagnostic disable-line deprecated
chunk, err = loadstring(str, chunkname) ---@diagnostic disable-line deprecated
if chunk then setfenv(chunk, env) end ---@diagnostic disable-line deprecated
else
chunk, err = load(str, chunkname, 't', env) ---@diagnostic disable-line redundant-parameter
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.
---@generic T
---@param t T
---@param references table<table,table>
---@param depth number
---@return T
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 --[[@as table<any,any>]]) do
key = copy_table_recursive(key, references, depth - 1)
copy[key] = copy_table_recursive(value, references, depth - 1) ---@diagnostic disable-line no-unknown
end
return copy
end
---A wrapper around copy_table to provide the reference table.
---@generic T
---@param t T
---@param depth? number
---@return T
function fb_utils.copy_table(t, depth)
--this is to handle cyclic table references
return copy_table_recursive(t, {}, depth or math.huge)
end
---@alias Replacer fun(item: Item, s: State): (string|number|nil)
---@alias ReplacerTable table<string,Replacer>
---functions to replace custom-keybind codes
---@type ReplacerTable
fb_utils.code_fns = {
["%"] = function() return "%" end,
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)
if #s.list == 0 then return 0 end
return ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i ~= -1 and i or 0) ---@diagnostic disable-line deprecated
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.
---@param codes ReplacerTable
---@return string
function fb_utils.get_code_pattern(codes)
---@type string
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 {} --[[@as ReplacerTable]]) do
---@type string
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.
---@param str string
---@param overrides? ReplacerTable Replacer functions for additional characters to match to after `%` characters.
---@param item? Item Uses the currently selected item if nil.
---@param state? State Uses the global state if nil.
---@param modifier_fn? fun(new_str: string, code: string): string given the replacement substrings before they are placed in the main string
--- (the return value is the new replacement string).
---@return 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)
---@type string|number|nil
local result
local replacer = replacers[code]
if type(replacer) == "string" then
result = replacer
--encapsulates the string if using an uppercase code
elseif not replacer then
local lower_fn = replacers[code:lower()]
if not lower_fn then return end
result = string.format("%q", lower_fn(item, state))
else
result = replacer(item, state)
end
if result and modifier_fn then return modifier_fn(tostring(result), code) end
return result
end))
end
return fb_utils
|