-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added explicit supported URL schemes matching for all players to redu…
…ce protocol abuse, added relevant section to README, added tests for new code
Showing
15 changed files
with
486 additions
and
349 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,17 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
|
||
<head> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||
<title>Document</title> | ||
<script src="common.js" type="module"></script> | ||
<script src="background.js" type="module"></script> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||
<title>Document</title> | ||
<script src="common.js" type="module"></script> | ||
<script src="background.js" type="module"></script> | ||
</head> | ||
|
||
<body> | ||
|
||
</body> | ||
</html> | ||
|
||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,130 +1,135 @@ | ||
class Option { | ||
constructor(name, type, defaultValue) { | ||
this.name = name; | ||
this.type = type; | ||
this.defaultValue = defaultValue; | ||
} | ||
constructor(name, type, defaultValue) { | ||
this.name = name | ||
this.type = type | ||
this.defaultValue = defaultValue | ||
} | ||
|
||
setValue(value) { | ||
switch (this.type) { | ||
case "radio": | ||
Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => { | ||
el.checked = el.value === value; | ||
}); | ||
break; | ||
case "checkbox": | ||
document.getElementsByName(this.name).forEach(el => el.checked = value); | ||
break; | ||
case "select": | ||
document.getElementsByName(this.name).forEach(el => el.value = value); | ||
break; | ||
case "text": | ||
document.getElementsByName(this.name).forEach(el => el.value = value); | ||
break; | ||
} | ||
setValue(value) { | ||
switch (this.type) { | ||
case "radio": | ||
Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => { | ||
el.checked = el.value === value | ||
}) | ||
break | ||
case "checkbox": | ||
document.getElementsByName(this.name).forEach(el => el.checked = value) | ||
break | ||
case "select": | ||
document.getElementsByName(this.name).forEach(el => el.value = value) | ||
break | ||
case "text": | ||
document.getElementsByName(this.name).forEach(el => el.value = value) | ||
break | ||
} | ||
} | ||
|
||
getValue() { | ||
switch (this.type) { | ||
case "radio": | ||
return document.querySelector(`input[name="${this.name}"]:checked`).value; | ||
case "checkbox": | ||
return document.querySelector(`input[name="${this.name}"]`).checked; | ||
case "select": | ||
return document.querySelector(`select[name="${this.name}"]`).value; | ||
case "text": | ||
return document.querySelector(`input[name="${this.name}"]`).value; | ||
} | ||
getValue() { | ||
switch (this.type) { | ||
case "radio": | ||
return document.querySelector(`input[name="${this.name}"]:checked`).value | ||
case "checkbox": | ||
return document.querySelector(`input[name="${this.name}"]`).checked | ||
case "select": | ||
return document.querySelector(`select[name="${this.name}"]`).value | ||
case "text": | ||
return document.querySelector(`input[name="${this.name}"]`).value | ||
} | ||
} | ||
} | ||
|
||
const _options = [ | ||
new Option("iconAction", "radio", "clickOnly"), | ||
new Option("iconActionOption", "radio", "direct"), | ||
new Option("mpvPlayer", "select", "mpv"), | ||
new Option("useCustomFlags", "checkbox", false), | ||
new Option("customFlags", "text", "") | ||
]; | ||
new Option("iconAction", "radio", "clickOnly"), | ||
new Option("iconActionOption", "radio", "direct"), | ||
new Option("mpvPlayer", "select", "mpv"), | ||
new Option("useCustomFlags", "checkbox", false), | ||
new Option("customFlags", "text", "") | ||
] | ||
|
||
export function getOptions(callback) { | ||
const getDict = {}; | ||
_options.forEach((item) => { | ||
getDict[item.name] = item.defaultValue; | ||
}) | ||
chrome.storage.sync.get(getDict, callback); | ||
const getDict = {} | ||
_options.forEach(item => { | ||
getDict[item.name] = item.defaultValue | ||
}) | ||
chrome.storage.sync.get(getDict, callback) | ||
} | ||
|
||
export function saveOptions() { | ||
const saveDict = {}; | ||
_options.forEach((item) => { | ||
saveDict[item.name] = item.getValue(); | ||
}) | ||
chrome.storage.sync.set(saveDict); | ||
const saveDict = {} | ||
_options.forEach(item => { | ||
saveDict[item.name] = item.getValue() | ||
}) | ||
chrome.storage.sync.set(saveDict) | ||
} | ||
|
||
export function restoreOptions() { | ||
getOptions((items) => { | ||
_options.forEach((option) => { | ||
option.setValue(items[option.name]); | ||
}); | ||
}); | ||
getOptions((items) => { | ||
_options.forEach(option => { | ||
option.setValue(items[option.name]) | ||
}) | ||
}) | ||
} | ||
|
||
export function openInMPV(tabId, url, options = {}) { | ||
const baseURL = `mpv:///open?`; | ||
|
||
// Encode video URL | ||
const params = [`url=${encodeURIComponent(url)}`]; | ||
|
||
// Add playback options | ||
switch (options.mode) { | ||
case "fullScreen": | ||
params.push("full_screen=1"); break; | ||
case "pip": | ||
params.push("pip=1"); break; | ||
case "enqueue": | ||
params.push("enqueue=1"); break; | ||
} | ||
const baseURL = `mpv:///open?` | ||
|
||
// Add new window option | ||
if (options.newWindow) { | ||
params.push("new_window=1"); | ||
} | ||
// Encode video URL | ||
const params = [`url=${encodeURIComponent(url)}`] | ||
|
||
// Add playback options | ||
switch (options.mode) { | ||
case "fullScreen": | ||
params.push("full_screen=1"); break | ||
case "pip": | ||
params.push("pip=1"); break | ||
case "enqueue": | ||
params.push("enqueue=1"); break | ||
} | ||
|
||
// Add alternative player and user-defined custom flags | ||
params.push(`player=${options.mpvPlayer}`); | ||
if (options.useCustomFlags && options.customFlags !== "") | ||
params.push(`flags=${encodeURIComponent(options.customFlags)}`); | ||
|
||
const code = ` | ||
var link = document.createElement('a'); | ||
link.href='${baseURL}${params.join("&")}'; | ||
document.body.appendChild(link); | ||
link.click(); | ||
`; | ||
console.log(code); | ||
chrome.tabs.executeScript(tabId, { code }); | ||
// Add new window option | ||
if (options.newWindow) { | ||
params.push("new_window=1") | ||
} | ||
|
||
// Add alternative player and user-defined custom flags | ||
params.push(`player=${options.mpvPlayer}`) | ||
if (options.useCustomFlags && options.customFlags !== "") | ||
params.push(`flags=${encodeURIComponent(options.customFlags)}`) | ||
|
||
const code = ` | ||
var link = document.createElement('a') | ||
link.href='${baseURL}${params.join("&")}' | ||
document.body.appendChild(link) | ||
link.click()` | ||
console.log(code) | ||
chrome.tabs.executeScript(tabId, { code }) | ||
} | ||
|
||
export function updateBrowserAction() { | ||
getOptions((options) => { | ||
if (options.iconAction === "clickOnly") { | ||
chrome.browserAction.setPopup({ popup: "" }); | ||
chrome.browserAction.onClicked.addListener(() => { | ||
// get active window | ||
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { | ||
if (tabs.length === 0) { return; } | ||
// TODO: filter url | ||
const tab = tabs[0]; | ||
if (tab.id === chrome.tabs.TAB_ID_NONE) { return; } | ||
openInMPV(tab.id, tab.url, { | ||
mode: options.iconActionOption, | ||
...options, | ||
}); | ||
}); | ||
}); | ||
} else { | ||
chrome.browserAction.setPopup({ popup: "popup.html" }); | ||
} | ||
}); | ||
getOptions(options => { | ||
if (options.iconAction === "clickOnly") { | ||
chrome.browserAction.setPopup({ popup: "" }) | ||
chrome.browserAction.onClicked.addListener(() => { | ||
// Get active tab | ||
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { | ||
if (tabs.length === 0) | ||
return | ||
|
||
// TODO: filter url | ||
const tab = tabs[0] | ||
if (tab.id === chrome.tabs.TAB_ID_NONE) | ||
return | ||
|
||
openInMPV(tab.id, tab.url, { | ||
mode: options.iconActionOption, | ||
...options, | ||
}) | ||
}) | ||
}) | ||
|
||
return | ||
} | ||
|
||
chrome.browserAction.setPopup({ popup: "popup.html" }) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,68 +1,68 @@ | ||
body { | ||
font-family: 'Inter', Arial, sans-serif; | ||
font-size: 14px; | ||
line-height: 1.4; | ||
font-family: 'Inter', Arial, sans-serif; | ||
font-size: 14px; | ||
line-height: 1.4; | ||
} | ||
|
||
.container { | ||
width: 90%; | ||
max-width: 880px; | ||
margin: 1rem auto; | ||
background: #f4f4f4; | ||
border-radius: 6px; | ||
box-shadow: 0 0 16px rgba(0,0,0,.2); | ||
width: 90%; | ||
max-width: 880px; | ||
margin: 1rem auto; | ||
background: #f4f4f4; | ||
border-radius: 6px; | ||
box-shadow: 0 0 16px rgba(0, 0, 0, .2); | ||
} | ||
|
||
.title { | ||
padding: 1rem; | ||
margin: 0; | ||
padding: 1rem; | ||
margin: 0; | ||
} | ||
|
||
.item:not(:last-child) { | ||
margin-bottom: 0.5rem; | ||
margin-bottom: 0.5rem; | ||
} | ||
|
||
.option { | ||
padding: 1rem; | ||
border-top: 1px solid #e4e4e4; | ||
padding: 1rem; | ||
border-top: 1px solid #e4e4e4; | ||
} | ||
|
||
.option:not(:last-child) { | ||
border-bottom: 1px solid #e4e4e4; | ||
border-bottom: 1px solid #e4e4e4; | ||
} | ||
|
||
.option .option-title { | ||
font-weight: bold; | ||
font-weight: bold; | ||
} | ||
|
||
.option .option-details { | ||
padding-left: 0.5rem; | ||
padding-left: 0.5rem; | ||
} | ||
|
||
.option .option-details:not(:first-child) { | ||
margin-top: 0.5rem; | ||
margin-top: 0.5rem; | ||
} | ||
|
||
.option .option-details:not(:last-child) { | ||
margin-bottom: 1rem; | ||
margin-bottom: 1rem; | ||
} | ||
|
||
.option-details > p { | ||
color: rgb(70, 70, 70); | ||
margin-block: 0; | ||
.option-details>p { | ||
color: rgb(70, 70, 70); | ||
margin-block: 0; | ||
} | ||
|
||
input[type="text"] { | ||
font-family: monospace; | ||
border: 1px solid rgb(169, 169, 169); | ||
margin: 0 0 0.5rem 0; | ||
padding: 0.2rem; | ||
border-radius: 6px; | ||
font-family: monospace; | ||
border: 1px solid rgb(169, 169, 169); | ||
margin: 0 0 0.5rem 0; | ||
padding: 0.2rem; | ||
border-radius: 6px; | ||
} | ||
|
||
select { | ||
margin: 0.1rem; | ||
padding: 0.2rem; | ||
border-radius: 6px; | ||
background-color: #fff; | ||
margin: 0.1rem; | ||
padding: 0.2rem; | ||
border-radius: 6px; | ||
background-color: #fff; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,61 +1,66 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
|
||
<head> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||
<title>Options</title> | ||
<script src="common.js" type="module"></script> | ||
<link rel="stylesheet" href="options.css"> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||
<title>Options</title> | ||
<script src="common.js" type="module"></script> | ||
<link rel="stylesheet" href="options.css"> | ||
</head> | ||
|
||
<body> | ||
<div class="container"> | ||
<h2 class="title">Player options</h2> | ||
<div class="option"> | ||
<label class="option-title"> Select the mpv-based player to use</label> | ||
<br> | ||
<select name="mpvPlayer"> | ||
<option value="mpv">mpv</option> | ||
<option value="celluloid">Celluloid</option> | ||
</select> | ||
</div> | ||
<div class="option"> | ||
<label class="option-title"><input type="checkbox" name="useCustomFlags"> Use custom command line flags</label> | ||
<div class="option-details" id="customFlagsContainer"> | ||
<input type="text" name="customFlags"> | ||
<p> Note: do include hyphens (e.g.<span style="font-family: monospace;">'--fs'</span>)</p> | ||
</div> | ||
</div> | ||
<div class="container"> | ||
<h2 class="title">Player options</h2> | ||
<div class="option"> | ||
<label class="option-title"> Select the mpv-based player to use</label> | ||
<br> | ||
<select name="mpvPlayer"> | ||
<option value="mpv">mpv</option> | ||
<option value="celluloid">Celluloid</option> | ||
</select> | ||
</div> | ||
<div class="container"> | ||
<h2 class="title">When clicking on the extension icon</h2> | ||
<div class="option"> | ||
<div class="item"> | ||
<label><input type="radio" name="iconAction" value="clickOnly"> Open current page in mpv</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconAction" value="menu"> Show a menu to select action</label> | ||
</div> | ||
</div> | ||
<div class="option"> | ||
<label class="option-title"><input type="checkbox" name="useCustomFlags"> Use custom command line flags</label> | ||
<div class="option-details" id="customFlagsContainer"> | ||
<input type="text" name="customFlags"> | ||
<p> Note: do include hyphens (e.g.<span style="font-family: monospace;">'--fs'</span>)</p> | ||
</div> | ||
</div> | ||
<div class="container"> | ||
<h2 class="title">Default open action</h2> | ||
<div class="option"> | ||
<div class="option-details"><p>Applies to "Open current page in mpv" and the right click context menu</p></div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="direct"> Open directly</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="fullScreen"> Enter full screen</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="pip"> Enter Picture-in-Picture</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="enqueue"> Add to queue</label> | ||
</div> | ||
</div> | ||
</div> | ||
<div class="container"> | ||
<h2 class="title">When clicking on the extension icon</h2> | ||
<div class="option"> | ||
<div class="item"> | ||
<label><input type="radio" name="iconAction" value="clickOnly"> Open current page in mpv</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconAction" value="menu"> Show a menu to select action</label> | ||
</div> | ||
</div> | ||
<script src="options.js" type="module"></script> | ||
</div> | ||
<div class="container"> | ||
<h2 class="title">Default open action</h2> | ||
<div class="option"> | ||
<div class="option-details"> | ||
<p>Applies to "Open current page in mpv" and the right click context menu</p> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="direct"> Open directly</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="fullScreen"> Enter full screen</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="pip"> Enter Picture-in-Picture</label> | ||
</div> | ||
<div class="item"> | ||
<label><input type="radio" name="iconActionOption" value="enqueue"> Add to queue</label> | ||
</div> | ||
</div> | ||
</div> | ||
<script src="options.js" type="module"></script> | ||
</body> | ||
</html> | ||
|
||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,11 @@ | ||
import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js"; | ||
import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js" | ||
|
||
function listener(el) { | ||
el.addEventListener("change", () => { | ||
saveOptions(); | ||
updateBrowserAction(); | ||
}); | ||
} | ||
const addListener = el => el.addEventListener("change", () => { | ||
saveOptions() | ||
updateBrowserAction() | ||
}) | ||
|
||
document.addEventListener("DOMContentLoaded", restoreOptions); | ||
document.addEventListener("DOMContentLoaded", restoreOptions) | ||
|
||
Array.prototype.forEach.call(document.getElementsByTagName("input"), listener); | ||
Array.prototype.forEach.call(document.getElementsByTagName("select"), listener); | ||
Array.prototype.forEach.call(document.getElementsByTagName("input"), addListener) | ||
Array.prototype.forEach.call(document.getElementsByTagName("select"), addListener) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,34 +1,39 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
|
||
<head> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||
<title>Document</title> | ||
<style> | ||
.menu { | ||
font-family: sans-serif; | ||
white-space: nowrap; | ||
} | ||
.menu .menu-item { | ||
padding: 0.5rem; | ||
transition: all 0.1s ease-out; | ||
cursor: pointer; | ||
border-radius: 6px; | ||
} | ||
.menu .menu-item:hover { | ||
background: #e4e4e4; | ||
} | ||
</style> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||
<title>Document</title> | ||
<style> | ||
.menu { | ||
font-family: sans-serif; | ||
white-space: nowrap; | ||
} | ||
|
||
.menu .menu-item { | ||
padding: 0.5rem; | ||
transition: all 0.1s ease-out; | ||
cursor: pointer; | ||
border-radius: 6px; | ||
} | ||
|
||
.menu .menu-item:hover { | ||
background: #e4e4e4; | ||
} | ||
</style> | ||
</head> | ||
|
||
<body> | ||
<div class="menu"> | ||
<div class="menu-item" id="open-normal">Open in mpv</div> | ||
<div class="menu-item" id="open-fullScreen">Open in mpv and enter full screen</div> | ||
<div class="menu-item" id="open-pip">Open in mpv and enter Picture-in-Picture</div> | ||
<div class="menu-item" id="open-newWindow">Open in a new mpv window</div> | ||
<div class="menu-item" id="open-enqueue">Add to queue (playlist)</div> | ||
</div> | ||
<script src="./popup.js" type="module"></script> | ||
<div class="menu"> | ||
<div class="menu-item" id="open-normal">Open in mpv</div> | ||
<div class="menu-item" id="open-fullScreen">Open in mpv and enter full screen</div> | ||
<div class="menu-item" id="open-pip">Open in mpv and enter Picture-in-Picture</div> | ||
<div class="menu-item" id="open-newWindow">Open in a new mpv window</div> | ||
<div class="menu-item" id="open-enqueue">Add to queue (playlist)</div> | ||
</div> | ||
<script src="./popup.js" type="module"></script> | ||
</body> | ||
</html> | ||
|
||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,24 @@ | ||
import { openInMPV, getOptions } from "./common.js"; | ||
import { openInMPV, getOptions } from "./common.js" | ||
|
||
Array.prototype.forEach.call(document.getElementsByClassName("menu-item"), (item) => { | ||
const mode = item.id.split("-")[1]; | ||
item.addEventListener("click", () => { | ||
getOptions((options) => { | ||
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { | ||
if (tabs.length === 0) { return; } | ||
const tab = tabs[0]; | ||
if (tab.id === chrome.tabs.TAB_ID_NONE) { return; } | ||
console.log(mode) | ||
openInMPV(tab.id, tab.url, { | ||
mode, | ||
newWindow: mode === "newWindow", | ||
...options, | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); | ||
Array.prototype.forEach.call(document.getElementsByClassName("menu-item"), item => { | ||
const mode = item.id.split("-")[1] | ||
item.addEventListener("click", () => { | ||
getOptions(options => { | ||
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { | ||
if (tabs.length === 0) | ||
return | ||
|
||
const tab = tabs[0] | ||
if (tab.id === chrome.tabs.TAB_ID_NONE) | ||
return | ||
|
||
console.log(mode) | ||
openInMPV(tab.id, tab.url, { | ||
mode, | ||
newWindow: mode === "newWindow", | ||
...options, | ||
}) | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters