feat: support for paste in menus
This adds several paste related features: - You can paste into search input box. - You can paste a url or a path into track select menus (subtitles/audio/video) to load it. - Menu API now accepts `on_paste` option, which works the same as `on_search`. closes #765, ref #497
This commit is contained in:
@@ -484,6 +484,7 @@ Menu {
|
|||||||
keep_open?: boolean;
|
keep_open?: boolean;
|
||||||
on_close?: string | string[];
|
on_close?: string | string[];
|
||||||
on_search?: string | string[];
|
on_search?: string | string[];
|
||||||
|
on_paste?: string | string[];
|
||||||
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
|
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
|
||||||
search_debounce?: 'submit' | number; // default: 0
|
search_debounce?: 'submit' | number; // default: 0
|
||||||
search_suggestion?: string;
|
search_suggestion?: string;
|
||||||
@@ -503,6 +504,7 @@ Submenu {
|
|||||||
separator?: boolean;
|
separator?: boolean;
|
||||||
keep_open?: boolean;
|
keep_open?: boolean;
|
||||||
on_search?: string | string[];
|
on_search?: string | string[];
|
||||||
|
on_paste?: string | string[];
|
||||||
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
|
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
|
||||||
search_debounce?: 'submit' | number; // default: 0
|
search_debounce?: 'submit' | number; // default: 0
|
||||||
search_suggestion?: string;
|
search_suggestion?: string;
|
||||||
@@ -544,6 +546,8 @@ While the menu is open this value will be available in `user-data/uosc/menu/type
|
|||||||
`item.icon` property accepts icon names. You can pick one from here: [Google Material Icons](https://fonts.google.com/icons?icon.platform=web&icon.set=Material+Icons&icon.style=Rounded)\
|
`item.icon` property accepts icon names. You can pick one from here: [Google Material Icons](https://fonts.google.com/icons?icon.platform=web&icon.set=Material+Icons&icon.style=Rounded)\
|
||||||
There is also a special icon name `spinner` which will display a rotating spinner. Along with a no-op command on an item and `keep_open=true`, this can be used to display placeholder menus/items that are still loading.
|
There is also a special icon name `spinner` which will display a rotating spinner. Along with a no-op command on an item and `keep_open=true`, this can be used to display placeholder menus/items that are still loading.
|
||||||
|
|
||||||
|
`on_paste` is triggered when user pastes a string while menu is opened. Works the same as `on_search`.
|
||||||
|
|
||||||
When `keep_open` is `true`, activating the item will not close the menu. This property can be defined on both menus and items, and is inherited from parent to child if child doesn't overwrite it.
|
When `keep_open` is `true`, activating the item will not close the menu. This property can be defined on both menus and items, and is inherited from parent to child if child doesn't overwrite it.
|
||||||
|
|
||||||
It's usually not necessary to define `selected_index` as it'll default to the first `active` item, or 1st item in the list.
|
It's usually not necessary to define `selected_index` as it'll default to the first `active` item, or 1st item in the list.
|
||||||
|
2
go.mod
2
go.mod
@@ -6,3 +6,5 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
|
||||||
k8s.io/apimachinery v0.28.3
|
k8s.io/apimachinery v0.28.3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
k8s.io/apimachinery v0.28.3 h1:B1wYx8txOaCQG0HmYF6nbpU8dg6HvA06x5tEffvOe7A=
|
k8s.io/apimachinery v0.28.3 h1:B1wYx8txOaCQG0HmYF6nbpU8dg6HvA06x5tEffvOe7A=
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
local Element = require('elements/Element')
|
local Element = require('elements/Element')
|
||||||
|
|
||||||
-- Menu data structure accepted by `Menu:open(menu)`.
|
-- Menu data structure accepted by `Menu:open(menu)`.
|
||||||
---@alias MenuData {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled'; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataItem[]; selected_index?: integer; on_search?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string}
|
---@alias MenuData {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled'; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataItem[]; selected_index?: integer; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string}
|
||||||
---@alias MenuDataItem MenuDataValue|MenuData
|
---@alias MenuDataItem MenuDataValue|MenuData
|
||||||
---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'}
|
---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'}
|
||||||
---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(); on_close?: fun(); on_back?: fun(); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
|
---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(); on_close?: fun(); on_back?: fun(); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
|
||||||
|
|
||||||
-- Internal data structure created from `Menu`.
|
-- Internal data structure created from `Menu`.
|
||||||
---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled', selected_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackItem[]; on_search?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string}
|
---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled', selected_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackItem[]; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string}
|
||||||
---@alias MenuStackItem MenuStackValue|MenuStack
|
---@alias MenuStackItem MenuStackValue|MenuStack
|
||||||
---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; title_width: number; hint_width: number}
|
---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; title_width: number; hint_width: number}
|
||||||
---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
|
---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
|
||||||
@@ -158,7 +158,7 @@ function Menu:update(data)
|
|||||||
local menus_to_serialize = {{new_root, data}}
|
local menus_to_serialize = {{new_root, data}}
|
||||||
local old_current_id = self.current and self.current.id
|
local old_current_id = self.current and self.current.id
|
||||||
local menu_props_to_copy = {
|
local menu_props_to_copy = {
|
||||||
'title', 'hint', 'keep_open', 'search_style', 'search_submenus', 'search_suggestion', 'on_search',
|
'title', 'hint', 'keep_open', 'search_style', 'search_submenus', 'search_suggestion', 'on_search', 'on_paste',
|
||||||
}
|
}
|
||||||
local item_props_to_copy = itable_join(menu_props_to_copy, {
|
local item_props_to_copy = itable_join(menu_props_to_copy, {
|
||||||
'icon', 'active', 'bold', 'italic', 'muted', 'value', 'separator', 'selectable', 'align',
|
'icon', 'active', 'bold', 'italic', 'muted', 'value', 'separator', 'selectable', 'align',
|
||||||
@@ -716,6 +716,26 @@ function Menu:on_end()
|
|||||||
self:navigate_by_offset(math.huge)
|
self:navigate_by_offset(math.huge)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Menu:paste()
|
||||||
|
local menu = self.current
|
||||||
|
local payload = get_clipboard()
|
||||||
|
if not payload then return end
|
||||||
|
if menu.search then
|
||||||
|
self:search_query_update(menu.search.query .. payload)
|
||||||
|
elseif menu.on_paste then
|
||||||
|
local paste_type = type(menu.on_paste)
|
||||||
|
if paste_type == 'string' then
|
||||||
|
mp.command(menu.on_paste .. ' ' .. payload)
|
||||||
|
elseif paste_type == 'table' then
|
||||||
|
local command = itable_join({}, menu.on_paste)
|
||||||
|
command[#command + 1] = payload
|
||||||
|
mp.command_native(command)
|
||||||
|
else
|
||||||
|
menu.on_paste(payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
---@param menu MenuStack
|
---@param menu MenuStack
|
||||||
---@param no_select_first? boolean
|
---@param no_select_first? boolean
|
||||||
function Menu:search_internal(menu, no_select_first)
|
function Menu:search_internal(menu, no_select_first)
|
||||||
@@ -1016,6 +1036,7 @@ function Menu:enable_key_bindings()
|
|||||||
self:add_key_binding('home', 'menu-home', self:create_key_action('on_home'))
|
self:add_key_binding('home', 'menu-home', self:create_key_action('on_home'))
|
||||||
self:add_key_binding('end', 'menu-end', self:create_key_action('on_end'))
|
self:add_key_binding('end', 'menu-end', self:create_key_action('on_end'))
|
||||||
self:add_key_binding('del', 'menu-delete-item', self:create_key_action('delete_selected_item'))
|
self:add_key_binding('del', 'menu-delete-item', self:create_key_action('delete_selected_item'))
|
||||||
|
self:add_key_binding('ctrl+v', 'menu-paste', self:create_key_action('paste'))
|
||||||
if self.type_to_search then
|
if self.type_to_search then
|
||||||
self:search_enable_key_bindings()
|
self:search_enable_key_bindings()
|
||||||
else
|
else
|
||||||
|
@@ -29,18 +29,18 @@ function toggle_menu_with_items(opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param options {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
|
---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_paste: fun(payload: string); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
|
||||||
function create_self_updating_menu_opener(options)
|
function create_self_updating_menu_opener(opts)
|
||||||
return function()
|
return function()
|
||||||
if Menu:is_open(options.type) then
|
if Menu:is_open(opts.type) then
|
||||||
Menu:close()
|
Menu:close()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local list = mp.get_property_native(options.list_prop)
|
local list = mp.get_property_native(opts.list_prop)
|
||||||
local active = options.active_prop and mp.get_property_native(options.active_prop) or nil
|
local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil
|
||||||
local menu
|
local menu
|
||||||
|
|
||||||
local function update() menu:update_items(options.serializer(list, active)) end
|
local function update() menu:update_items(opts.serializer(list, active)) end
|
||||||
|
|
||||||
local ignore_initial_list = true
|
local ignore_initial_list = true
|
||||||
local function handle_list_prop_change(name, value)
|
local function handle_list_prop_change(name, value)
|
||||||
@@ -62,25 +62,31 @@ function create_self_updating_menu_opener(options)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local initial_items, selected_index = options.serializer(list, active)
|
local initial_items, selected_index = opts.serializer(list, active)
|
||||||
|
|
||||||
-- Items and active_index are set in the handle_prop_change callback, since adding
|
-- Items and active_index are set in the handle_prop_change callback, since adding
|
||||||
-- a property observer triggers its handler immediately, we just let that initialize the items.
|
-- a property observer triggers its handler immediately, we just let that initialize the items.
|
||||||
menu = Menu:open(
|
menu = Menu:open(
|
||||||
{type = options.type, title = options.title, items = initial_items, selected_index = selected_index},
|
{
|
||||||
options.on_select, {
|
type = opts.type,
|
||||||
|
title = opts.title,
|
||||||
|
items = initial_items,
|
||||||
|
selected_index = selected_index,
|
||||||
|
on_paste = opts.on_paste,
|
||||||
|
},
|
||||||
|
opts.on_select, {
|
||||||
on_open = function()
|
on_open = function()
|
||||||
mp.observe_property(options.list_prop, 'native', handle_list_prop_change)
|
mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
|
||||||
if options.active_prop then
|
if opts.active_prop then
|
||||||
mp.observe_property(options.active_prop, 'native', handle_active_prop_change)
|
mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
on_close = function()
|
on_close = function()
|
||||||
mp.unobserve_property(handle_list_prop_change)
|
mp.unobserve_property(handle_list_prop_change)
|
||||||
mp.unobserve_property(handle_active_prop_change)
|
mp.unobserve_property(handle_active_prop_change)
|
||||||
end,
|
end,
|
||||||
on_move_item = options.on_move_item,
|
on_move_item = opts.on_move_item,
|
||||||
on_delete_item = options.on_delete_item,
|
on_delete_item = opts.on_delete_item,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -155,7 +161,7 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
|||||||
return items, active_index or first_item_index
|
return items, active_index or first_item_index
|
||||||
end
|
end
|
||||||
|
|
||||||
local function selection_handler(value)
|
local function handle_select(value)
|
||||||
if value == '{download}' then
|
if value == '{download}' then
|
||||||
mp.command(download_command)
|
mp.command(download_command)
|
||||||
elseif value == '{load}' then
|
elseif value == '{load}' then
|
||||||
@@ -170,12 +176,21 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function handle_paste(value)
|
||||||
|
mp.commandv(track_type .. '-add', value)
|
||||||
|
-- If subtitle track was loaded, assume the user also wants to see it
|
||||||
|
if track_type == 'sub' then
|
||||||
|
mp.commandv('set', 'sub-visibility', 'yes')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return create_self_updating_menu_opener({
|
return create_self_updating_menu_opener({
|
||||||
title = menu_title,
|
title = menu_title,
|
||||||
type = track_type,
|
type = track_type,
|
||||||
list_prop = 'track-list',
|
list_prop = 'track-list',
|
||||||
serializer = serialize_tracklist,
|
serializer = serialize_tracklist,
|
||||||
on_select = selection_handler,
|
on_select = handle_select,
|
||||||
|
on_paste = handle_paste,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@@ -728,6 +728,32 @@ function find_active_keybindings(key)
|
|||||||
return not key and active or active[key]
|
return not key and active or active[key]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return string|nil
|
||||||
|
function get_clipboard()
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = 'subprocess',
|
||||||
|
capture_stderr = true,
|
||||||
|
capture_stdout = true,
|
||||||
|
playback_only = false,
|
||||||
|
args = {config.ziggy_path, 'get-clipboard'},
|
||||||
|
})
|
||||||
|
|
||||||
|
local function print_error(message)
|
||||||
|
msg.error('Getting clipboard data failed. Error: ' .. message)
|
||||||
|
end
|
||||||
|
|
||||||
|
if result.status == 0 then
|
||||||
|
local data = utils.parse_json(result.stdout)
|
||||||
|
if data and data.payload then
|
||||||
|
return data.payload
|
||||||
|
else
|
||||||
|
print_error(data and (data.error and data.message or 'unknown error') or 'couldn\'t parse json')
|
||||||
|
end
|
||||||
|
else
|
||||||
|
print_error('exit code ' .. result.status .. ': ' .. result.stdout .. result.stderr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
--[[ RENDERING ]]
|
--[[ RENDERING ]]
|
||||||
|
|
||||||
function render()
|
function render()
|
||||||
|
39
src/ziggy/commands/clipboard.go
Normal file
39
src/ziggy/commands/clipboard.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"uosc/bins/src/ziggy/lib"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClipboardResult struct {
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClipboard(_ []string) {
|
||||||
|
fmt.Print(string(lib.Must(lib.JSONMarshal(ClipboardResult{
|
||||||
|
Payload: lib.Must(clipboard.ReadAll()),
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetClipboard(args []string) {
|
||||||
|
cmd := flag.NewFlagSet("set-clipboard", flag.ExitOnError)
|
||||||
|
|
||||||
|
lib.Check(cmd.Parse(args))
|
||||||
|
|
||||||
|
values := cmd.Args()
|
||||||
|
value := ""
|
||||||
|
if len(values) > 0 {
|
||||||
|
value = values[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
lib.Check(cmd.Parse(args))
|
||||||
|
|
||||||
|
lib.Check(clipboard.WriteAll(value))
|
||||||
|
|
||||||
|
fmt.Print(string(lib.Must(lib.JSONMarshal(ClipboardResult{
|
||||||
|
Payload: value,
|
||||||
|
}))))
|
||||||
|
}
|
175
src/ziggy/commands/subtitles.go
Normal file
175
src/ziggy/commands/subtitles.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"uosc/bins/src/ziggy/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
const OPEN_SUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1"
|
||||||
|
|
||||||
|
type DownloadRequestData struct {
|
||||||
|
FileId int `json:"file_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadResponseData struct {
|
||||||
|
Link string `json:"link"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
Remaining int `json:"remaining"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ResetTime string `json:"reset_time"`
|
||||||
|
ResetTimeUTC string `json:"reset_time_utc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadData struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Remaining int `json:"remaining"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
ResetTime string `json:"reset_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchSubtitles(args []string) {
|
||||||
|
cmd := flag.NewFlagSet("search-subtitles", flag.ExitOnError)
|
||||||
|
argApiKey := cmd.String("api-key", "", "Open Subtitles consumer API key.")
|
||||||
|
argAgent := cmd.String("agent", "", "User-Agent header. Format: appname v1.0")
|
||||||
|
argLanguages := cmd.String("languages", "", "What languages to search for.")
|
||||||
|
argHash := cmd.String("hash", "", "What file to hash and add to search query.")
|
||||||
|
argQuery := cmd.String("query", "", "String query to use.")
|
||||||
|
argPage := cmd.Int("page", 1, "Results page, starting at 1.")
|
||||||
|
|
||||||
|
lib.Check(cmd.Parse(args))
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if len(*argApiKey) == 0 {
|
||||||
|
lib.Check(errors.New("--api-key is required"))
|
||||||
|
}
|
||||||
|
if len(*argAgent) == 0 {
|
||||||
|
lib.Check(errors.New("--agent is required"))
|
||||||
|
}
|
||||||
|
if len(*argHash) == 0 && len(*argQuery) == 0 {
|
||||||
|
lib.Check(errors.New("at least one of --query or --hash is required"))
|
||||||
|
}
|
||||||
|
if len(*argLanguages) == 0 {
|
||||||
|
lib.Check(errors.New("--languages is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Send request parameters sorted, and send all queries in lowercase."
|
||||||
|
params := []string{}
|
||||||
|
languageDelimiterRE := regexp.MustCompile(" *, *")
|
||||||
|
languages := languageDelimiterRE.Split(*argLanguages, -1)
|
||||||
|
slices.Sort(languages)
|
||||||
|
params = append(params, "languages="+escapeParam(strings.Join(languages, ",")))
|
||||||
|
if len(*argHash) > 0 {
|
||||||
|
hash, err := lib.OSDBHashFile(*argHash)
|
||||||
|
if err == nil {
|
||||||
|
params = append(params, "moviehash="+escapeParam(hash))
|
||||||
|
} else if len(*argQuery) == 0 {
|
||||||
|
lib.Check(fmt.Errorf("couldn't hash the file (%w) and query is empty", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params = append(params, "page="+escapeParam(fmt.Sprint(*argPage)))
|
||||||
|
if len(*argQuery) > 0 {
|
||||||
|
params = append(params, "query="+escapeParam(*argQuery))
|
||||||
|
}
|
||||||
|
|
||||||
|
client := http.Client{}
|
||||||
|
req := lib.Must(http.NewRequest("GET", OPEN_SUBTITLES_API_URL+"/subtitles?"+strings.Join(params, "&"), nil))
|
||||||
|
req.Header = http.Header{
|
||||||
|
"Api-Key": {*argApiKey},
|
||||||
|
"User-Agent": {*argAgent},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := lib.Must(client.Do(req))
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
fmt.Print(string(lib.Must(io.ReadAll(resp.Body))))
|
||||||
|
} else {
|
||||||
|
lib.Check(errors.New(resp.Status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadSubtitles(args []string) {
|
||||||
|
cmd := flag.NewFlagSet("download-subtitles", flag.ExitOnError)
|
||||||
|
argApiKey := cmd.String("api-key", "", "Open Subtitles consumer API key.")
|
||||||
|
argAgent := cmd.String("agent", "", "User-Agent header. Format: appname v1.0")
|
||||||
|
argFileID := cmd.Int("file-id", 0, "Subtitle file ID to download.")
|
||||||
|
argDestination := cmd.String("destination", "", "Destination directory.")
|
||||||
|
|
||||||
|
lib.Check(cmd.Parse(args))
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if len(*argApiKey) == 0 {
|
||||||
|
lib.Check(errors.New("--api-key is required"))
|
||||||
|
}
|
||||||
|
if len(*argAgent) == 0 {
|
||||||
|
lib.Check(errors.New("--agent is required"))
|
||||||
|
}
|
||||||
|
if *argFileID == 0 {
|
||||||
|
lib.Check(errors.New("--file-id is required"))
|
||||||
|
}
|
||||||
|
if len(*argDestination) == 0 {
|
||||||
|
lib.Check(errors.New("--destination is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the directory if it doesn't exist
|
||||||
|
if _, err := os.Stat(*argDestination); os.IsNotExist(err) {
|
||||||
|
os.MkdirAll(*argDestination, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := bytes.NewBuffer(lib.Must(lib.JSONMarshal(DownloadRequestData{FileId: *argFileID})))
|
||||||
|
client := http.Client{}
|
||||||
|
req := lib.Must(http.NewRequest("POST", OPEN_SUBTITLES_API_URL+"/download", data))
|
||||||
|
req.Header = http.Header{
|
||||||
|
"Accept": {"application/json"},
|
||||||
|
"Api-Key": {*argApiKey},
|
||||||
|
"Content-Type": {"application/json"},
|
||||||
|
"User-Agent": {*argAgent},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := lib.Must(client.Do(req))
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
lib.Check(errors.New(resp.Status))
|
||||||
|
}
|
||||||
|
var downloadData DownloadResponseData
|
||||||
|
lib.Check(json.Unmarshal(lib.Must(io.ReadAll(resp.Body)), &downloadData))
|
||||||
|
filePath := filepath.Join(*argDestination, downloadData.FileName)
|
||||||
|
outFile := lib.Must(os.Create(filePath))
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
response := lib.Must(http.Get(downloadData.Link))
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
lib.Check(fmt.Errorf("downloading failed: %s", response.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
lib.Must(io.Copy(outFile, response.Body))
|
||||||
|
|
||||||
|
fmt.Print(string(lib.Must(lib.JSONMarshal(DownloadData{
|
||||||
|
File: filePath,
|
||||||
|
Remaining: downloadData.Remaining,
|
||||||
|
Total: downloadData.Remaining + downloadData.Requests,
|
||||||
|
ResetTime: downloadData.ResetTime,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape and lowercase (open subtitles requirement) a URL parameter
|
||||||
|
func escapeParam(str string) string {
|
||||||
|
return url.QueryEscape(strings.ToLower(str))
|
||||||
|
}
|
98
src/ziggy/lib/utils.go
Normal file
98
src/ziggy/lib/utils.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorData struct {
|
||||||
|
Error bool `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Check(err error) {
|
||||||
|
if err != nil {
|
||||||
|
res := ErrorData{Error: true, Message: err.Error()}
|
||||||
|
json, err := json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Print(string(json))
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Must[T any](t T, err error) T {
|
||||||
|
Check(err)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
const OSDBChunkSize = 65536 // 64k
|
||||||
|
|
||||||
|
// Generate an OSDB hash for a file.
|
||||||
|
func OSDBHashFile(filePath string) (hash string, err error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("couldn't open file for hashing")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("couldn't stat file for hashing")
|
||||||
|
}
|
||||||
|
if fi.Size() < OSDBChunkSize {
|
||||||
|
return "", errors.New("file is too small to generate a valid OSDB hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read head and tail blocks
|
||||||
|
buf := make([]byte, OSDBChunkSize*2)
|
||||||
|
err = readChunk(file, 0, buf[:OSDBChunkSize])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = readChunk(file, fi.Size()-OSDBChunkSize, buf[OSDBChunkSize:])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to uint64, and sum
|
||||||
|
var nums [(OSDBChunkSize * 2) / 8]uint64
|
||||||
|
reader := bytes.NewReader(buf)
|
||||||
|
err = binary.Read(reader, binary.LittleEndian, &nums)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var hashUint uint64
|
||||||
|
for _, num := range nums {
|
||||||
|
hashUint += num
|
||||||
|
}
|
||||||
|
|
||||||
|
hashUint = hashUint + uint64(fi.Size())
|
||||||
|
|
||||||
|
return fmt.Sprintf("%016x", hashUint), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a chunk of a file at `offset` so as to fill `buf`.
|
||||||
|
func readChunk(file *os.File, offset int64, buf []byte) (err error) {
|
||||||
|
n, err := file.ReadAt(buf, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != OSDBChunkSize {
|
||||||
|
return fmt.Errorf("invalid read %v", n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Because the default `json.Marshal` HTML escapes `&,<,>` characters and it can't be turned off...
|
||||||
|
func JSONMarshal(t interface{}) ([]byte, error) {
|
||||||
|
buffer := &bytes.Buffer{}
|
||||||
|
encoder := json.NewEncoder(buffer)
|
||||||
|
encoder.SetEscapeHTML(false)
|
||||||
|
err := encoder.Encode(t)
|
||||||
|
return buffer.Bytes(), err
|
||||||
|
}
|
@@ -1,262 +1,33 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"uosc/bins/src/ziggy/commands"
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const OPEN_SUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1"
|
|
||||||
const OSDBChunkSize = 65536 // 64k
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
srSubsCmd := flag.NewFlagSet("search-subtitles", flag.ExitOnError)
|
command := "help"
|
||||||
srSubsApiKey := srSubsCmd.String("api-key", "", "Open Subtitles consumer API key.")
|
args := os.Args[2:]
|
||||||
srSubsAgent := srSubsCmd.String("agent", "", "User-Agent header. Format: appname v1.0")
|
|
||||||
srSubsLanguages := srSubsCmd.String("languages", "", "What languages to search for.")
|
|
||||||
srSubsHash := srSubsCmd.String("hash", "", "What file to hash and add to search query.")
|
|
||||||
srSubsQuery := srSubsCmd.String("query", "", "String query to use.")
|
|
||||||
srSubsPage := srSubsCmd.Int("page", 1, "Results page, starting at 1.")
|
|
||||||
|
|
||||||
dlSubsCmd := flag.NewFlagSet("download-subtitles", flag.ExitOnError)
|
if len(os.Args) > 1 {
|
||||||
dlSubsApiKey := dlSubsCmd.String("api-key", "", "Open Subtitles consumer API key.")
|
command = os.Args[1]
|
||||||
dlSubsAgent := dlSubsCmd.String("agent", "", "User-Agent header. Format: appname v1.0")
|
|
||||||
dlSubsID := dlSubsCmd.Int("file-id", 0, "Subtitle file ID to download.")
|
|
||||||
dlSubsDestination := dlSubsCmd.String("destination", "", "Destination directory.")
|
|
||||||
|
|
||||||
if len(os.Args) <= 1 {
|
|
||||||
panic(errors.New("command required"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch os.Args[1] {
|
switch command {
|
||||||
case "search-subtitles":
|
case "search-subtitles":
|
||||||
check(srSubsCmd.Parse(os.Args[2:]))
|
commands.SearchSubtitles(args)
|
||||||
|
|
||||||
// Validation
|
|
||||||
if len(*srSubsApiKey) == 0 {
|
|
||||||
check(errors.New("--api-key is required"))
|
|
||||||
}
|
|
||||||
if len(*srSubsAgent) == 0 {
|
|
||||||
check(errors.New("--agent is required"))
|
|
||||||
}
|
|
||||||
if len(*srSubsHash) == 0 && len(*srSubsQuery) == 0 {
|
|
||||||
check(errors.New("at least one of --query or --hash is required"))
|
|
||||||
}
|
|
||||||
if len(*srSubsLanguages) == 0 {
|
|
||||||
check(errors.New("--languages is required"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Send request parameters sorted, and send all queries in lowercase."
|
|
||||||
params := []string{}
|
|
||||||
languageDelimiterRE := regexp.MustCompile(" *, *")
|
|
||||||
languages := languageDelimiterRE.Split(*srSubsLanguages, -1)
|
|
||||||
slices.Sort(languages)
|
|
||||||
params = append(params, "languages="+escape(strings.Join(languages, ",")))
|
|
||||||
if len(*srSubsHash) > 0 {
|
|
||||||
params = append(params, "moviehash="+escape(must(hashFile(*srSubsHash))))
|
|
||||||
}
|
|
||||||
params = append(params, "page="+escape(fmt.Sprint(*srSubsPage)))
|
|
||||||
if len(*srSubsQuery) > 0 {
|
|
||||||
params = append(params, "query="+escape(*srSubsQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
client := http.Client{}
|
|
||||||
req := must(http.NewRequest("GET", OPEN_SUBTITLES_API_URL+"/subtitles?"+strings.Join(params, "&"), nil))
|
|
||||||
req.Header = http.Header{
|
|
||||||
"Api-Key": {*srSubsApiKey},
|
|
||||||
"User-Agent": {*srSubsAgent},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := must(client.Do(req))
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
fmt.Print(string(must(io.ReadAll(resp.Body))))
|
|
||||||
} else {
|
|
||||||
check(errors.New(resp.Status))
|
|
||||||
}
|
|
||||||
|
|
||||||
case "download-subtitles":
|
case "download-subtitles":
|
||||||
check(dlSubsCmd.Parse(os.Args[2:]))
|
commands.DownloadSubtitles(args)
|
||||||
|
|
||||||
// Validation
|
case "get-clipboard":
|
||||||
if len(*dlSubsApiKey) == 0 {
|
commands.GetClipboard(args)
|
||||||
check(errors.New("--api-key is required"))
|
|
||||||
}
|
|
||||||
if len(*dlSubsAgent) == 0 {
|
|
||||||
check(errors.New("--agent is required"))
|
|
||||||
}
|
|
||||||
if *dlSubsID == 0 {
|
|
||||||
check(errors.New("--file-id is required"))
|
|
||||||
}
|
|
||||||
if len(*dlSubsDestination) == 0 {
|
|
||||||
check(errors.New("--destination is required"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the directory if it doesn't exist
|
case "set-clipboard":
|
||||||
if _, err := os.Stat(*dlSubsDestination); os.IsNotExist(err) {
|
commands.SetClipboard(args)
|
||||||
os.MkdirAll(*dlSubsDestination, 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := bytes.NewBuffer(must(JSONMarshal(DownloadRequestData{FileId: *dlSubsID})))
|
default:
|
||||||
client := http.Client{}
|
panic(errors.New("command required"))
|
||||||
req := must(http.NewRequest("POST", OPEN_SUBTITLES_API_URL+"/download", data))
|
|
||||||
req.Header = http.Header{
|
|
||||||
"Accept": {"application/json"},
|
|
||||||
"Api-Key": {*dlSubsApiKey},
|
|
||||||
"Content-Type": {"application/json"},
|
|
||||||
"User-Agent": {*dlSubsAgent},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := must(client.Do(req))
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
check(errors.New(resp.Status))
|
|
||||||
}
|
|
||||||
var downloadData DownloadResponseData
|
|
||||||
check(json.Unmarshal(must(io.ReadAll(resp.Body)), &downloadData))
|
|
||||||
filePath := filepath.Join(*dlSubsDestination, downloadData.FileName)
|
|
||||||
outFile := must(os.Create(filePath))
|
|
||||||
defer outFile.Close()
|
|
||||||
|
|
||||||
response := must(http.Get(downloadData.Link))
|
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
|
||||||
check(fmt.Errorf("downloading failed: %s", response.Status))
|
|
||||||
}
|
|
||||||
|
|
||||||
must(io.Copy(outFile, response.Body))
|
|
||||||
|
|
||||||
fmt.Print(string(must(JSONMarshal(DownloadData{
|
|
||||||
File: filePath,
|
|
||||||
Remaining: downloadData.Remaining,
|
|
||||||
Total: downloadData.Remaining + downloadData.Requests,
|
|
||||||
ResetTime: downloadData.ResetTime,
|
|
||||||
}))))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadRequestData struct {
|
|
||||||
FileId int `json:"file_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DownloadResponseData struct {
|
|
||||||
Link string `json:"link"`
|
|
||||||
FileName string `json:"file_name"`
|
|
||||||
Requests int `json:"requests"`
|
|
||||||
Remaining int `json:"remaining"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
ResetTime string `json:"reset_time"`
|
|
||||||
ResetTimeUTC string `json:"reset_time_utc"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DownloadData struct {
|
|
||||||
File string `json:"file"`
|
|
||||||
Remaining int `json:"remaining"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
ResetTime string `json:"reset_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrorData struct {
|
|
||||||
Error bool `json:"error"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func check(err error) {
|
|
||||||
if err != nil {
|
|
||||||
res := ErrorData{Error: true, Message: err.Error()}
|
|
||||||
json, err := json.Marshal(res)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Print(string(json))
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func must[T any](t T, err error) T {
|
|
||||||
check(err)
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape and lowercase (open subtitles requirement) a URL parameter
|
|
||||||
func escape(str string) string {
|
|
||||||
return url.QueryEscape(strings.ToLower(str))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate an OSDB hash for a file
|
|
||||||
func hashFile(filePath string) (hash string, err error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.New("couldn't open file for hashing")
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.New("couldn't stat file for hashing")
|
|
||||||
}
|
|
||||||
if fi.Size() < OSDBChunkSize {
|
|
||||||
return "", errors.New("file is too small to generate a valid OSDB hash")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read head and tail blocks
|
|
||||||
buf := make([]byte, OSDBChunkSize*2)
|
|
||||||
err = readChunk(file, 0, buf[:OSDBChunkSize])
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = readChunk(file, fi.Size()-OSDBChunkSize, buf[OSDBChunkSize:])
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to uint64, and sum
|
|
||||||
var nums [(OSDBChunkSize * 2) / 8]uint64
|
|
||||||
reader := bytes.NewReader(buf)
|
|
||||||
err = binary.Read(reader, binary.LittleEndian, &nums)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var hashUint uint64
|
|
||||||
for _, num := range nums {
|
|
||||||
hashUint += num
|
|
||||||
}
|
|
||||||
|
|
||||||
hashUint = hashUint + uint64(fi.Size())
|
|
||||||
|
|
||||||
return fmt.Sprintf("%016x", hashUint), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read a chunk of a file at `offset` so as to fill `buf`
|
|
||||||
func readChunk(file *os.File, offset int64, buf []byte) (err error) {
|
|
||||||
n, err := file.ReadAt(buf, offset)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n != OSDBChunkSize {
|
|
||||||
return fmt.Errorf("invalid read %v", n)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Because the default `json.Marshal` HTML escapes `&,<,>` characters and it can't be turned off...
|
|
||||||
func JSONMarshal(t interface{}) ([]byte, error) {
|
|
||||||
buffer := &bytes.Buffer{}
|
|
||||||
encoder := json.NewEncoder(buffer)
|
|
||||||
encoder.SetEscapeHTML(false)
|
|
||||||
err := encoder.Encode(t)
|
|
||||||
return buffer.Bytes(), err
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user