Compare commits
10 commits
ffba917aae
...
246150d556
Author | SHA1 | Date | |
---|---|---|---|
|
246150d556 | ||
|
0e636aa9ef | ||
|
10db1f235a | ||
|
fdfb5f7dbd | ||
|
2e0c6686a3 | ||
|
9d75ea5ebf | ||
|
85afb63789 | ||
|
16779b0d6c | ||
|
b33d855306 | ||
|
1e3897f599 |
7 changed files with 197 additions and 142 deletions
2
.env
2
.env
|
@ -1 +1 @@
|
|||
REACT_APP_APISERVER=https://api.audible-converter.ml
|
||||
REACT_APP_APISERVER=https://aaxapiserverfunction20220831180001.azurewebsites.net
|
|
@ -1 +1 @@
|
|||
REACT_APP_APISERVER=https://api.audible-converter.ml
|
||||
REACT_APP_APISERVER=https://aaxapiserverfunction20220831180001.azurewebsites.net
|
|
@ -9,6 +9,9 @@ on:
|
|||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||
|
@ -20,11 +23,13 @@ jobs:
|
|||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install Dependencies
|
||||
run: npm install --legacy-peer-deps
|
||||
- name: Build And Deploy
|
||||
id: builddeploy
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_POLITE_ISLAND_035A0B503 }}
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ZEALOUS_MOSS_0CC734503 }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
|
||||
action: "upload"
|
||||
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
|
||||
|
@ -32,6 +37,7 @@ jobs:
|
|||
app_location: "/" # App source code path
|
||||
api_location: "api" # Api source code path - optional
|
||||
output_location: "build" # Built app content directory - optional
|
||||
app_build_command: "npm run build" # Build command - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
close_pull_request_job:
|
||||
|
@ -43,5 +49,5 @@ jobs:
|
|||
id: closepullrequest
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_POLITE_ISLAND_035A0B503 }}
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ZEALOUS_MOSS_0CC734503 }}
|
||||
action: "close"
|
||||
|
|
15
LICENSE
Normal file
15
LICENSE
Normal file
|
@ -0,0 +1,15 @@
|
|||
Modified MIT License
|
||||
|
||||
Copyright (c) 2024 audible-tools
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, and publish, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
1. The Software, or derivatives of the Software, may not be used for commercial purposes.
|
||||
|
||||
2. Any use or display of the Software must include an acknowledgement to the original Software by Full name.
|
||||
|
||||
3. Any changes made to the Software must be made publicly available, preferably by creating a pull request to the original repository of the Software.
|
||||
|
||||
The above copyright notice, this permission notice, and the above conditions shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
BIN
public/paypal_PNG7.png
Normal file
BIN
public/paypal_PNG7.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
|
@ -1,82 +1,78 @@
|
|||
import React, { useState } from 'react'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import Avatar from '@material-ui/core/Avatar'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import TextField from '@material-ui/core/TextField'
|
||||
import React, { useState } from "react";
|
||||
import { withStyles } from "@material-ui/core/styles";
|
||||
import Avatar from "@material-ui/core/Avatar";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import CssBaseline from "@material-ui/core/CssBaseline";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
|
||||
import Link from '@material-ui/core/Link'
|
||||
import Box from '@material-ui/core/Box'
|
||||
import LockOutlinedIcon from '@material-ui/icons/LockOutlined'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import Container from '@material-ui/core/Container'
|
||||
import Link from "@material-ui/core/Link";
|
||||
import Box from "@material-ui/core/Box";
|
||||
import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Container from "@material-ui/core/Container";
|
||||
|
||||
import Dropzone from 'react-dropzone'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import FileCopyOutlined from '@material-ui/icons/FileCopyOutlined'
|
||||
import PublishOutlined from '@material-ui/icons/PublishOutlined'
|
||||
import Dropzone from "react-dropzone";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import FileCopyOutlined from "@material-ui/icons/FileCopyOutlined";
|
||||
import PublishOutlined from "@material-ui/icons/PublishOutlined";
|
||||
|
||||
import { FilePicker } from '../src/Components'
|
||||
import { FilePicker } from "../src/Components";
|
||||
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard'
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
|
||||
import ControlledAccordions from './ControlledAccordions'
|
||||
import OnlineConverter from './OnlineConverter'
|
||||
import 'react-notifications-component/dist/theme.css'
|
||||
import ControlledAccordions from "./ControlledAccordions";
|
||||
import OnlineConverter from "./OnlineConverter";
|
||||
import "react-notifications-component/dist/theme.css";
|
||||
|
||||
import ReactNotification from 'react-notifications-component'
|
||||
import { store } from 'react-notifications-component'
|
||||
import ReactNotification from "react-notifications-component";
|
||||
import { store } from "react-notifications-component";
|
||||
|
||||
import AaxHashAlgorithm from './Utils/AaxHashAlgorithm'
|
||||
|
||||
import {
|
||||
GoogleReCaptchaProvider,
|
||||
withGoogleReCaptcha,
|
||||
} from 'react-google-recaptcha-v3'
|
||||
import AaxHashAlgorithm from "./Utils/AaxHashAlgorithm";
|
||||
|
||||
import { GoogleReCaptchaProvider, withGoogleReCaptcha } from "react-google-recaptcha-v3";
|
||||
|
||||
const useStyles = (theme) => ({
|
||||
paper: {
|
||||
marginTop: theme.spacing(8),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
avatar: {
|
||||
margin: theme.spacing(1),
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
},
|
||||
form: {
|
||||
width: '100%', // Fix IE 11 issue.
|
||||
width: "100%", // Fix IE 11 issue.
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
|
||||
//Accordeon
|
||||
heading: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
flexBasis: '33.33%',
|
||||
flexBasis: "33.33%",
|
||||
flexShrink: 0,
|
||||
},
|
||||
secondaryHeading: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
class ChecksumResolver extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
super(props);
|
||||
this.state = {
|
||||
checksum: '',
|
||||
fileName: 'input.aax',
|
||||
activationBytes: '',
|
||||
}
|
||||
checksum: "",
|
||||
fileName: "input.aax",
|
||||
activationBytes: "",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let path = window.location.pathname;
|
||||
let checksumMatch = window.location.pathname.match(/([a-fA-F0-9]{40})/)
|
||||
if(!checksumMatch) return;
|
||||
let checksumMatch = window.location.pathname.match(/([a-fA-F0-9]{40})/);
|
||||
if (!checksumMatch) return;
|
||||
let checksum = checksumMatch[1];
|
||||
// this.setState({});
|
||||
this.setChecksum(checksum);
|
||||
|
@ -90,151 +86,168 @@ class ChecksumResolver extends React.Component {
|
|||
DarkerDisabledTextField = withStyles({
|
||||
root: {
|
||||
marginRight: 8,
|
||||
'& .MuiInputBase-root.Mui-disabled': {
|
||||
color: 'rgba(0, 0, 0, 0.6)',
|
||||
"& .MuiInputBase-root.Mui-disabled": {
|
||||
color: "rgba(0, 0, 0, 0.6)",
|
||||
},
|
||||
},
|
||||
})(TextField)
|
||||
})(TextField);
|
||||
|
||||
Copyright = function () {
|
||||
return (
|
||||
<Typography variant="body2" color="textSecondary" align="center">
|
||||
{'Copyright © '}
|
||||
{"Copyright © "}
|
||||
<Link color="inherit" href="https://audible-tools.github.io/">
|
||||
audible-tools
|
||||
</Link>{' '}
|
||||
</Link>{" "}
|
||||
{new Date().getFullYear()}
|
||||
{'. V 0.3'}
|
||||
{". V 0.3"}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
setChecksum = (value) => {
|
||||
if (value.length > 40) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
this.setState({ checksum: value })
|
||||
}
|
||||
this.setState({ checksum: value });
|
||||
};
|
||||
|
||||
isChecksumValid = () => {
|
||||
const { checksum } = this.state
|
||||
const regex = RegExp('[a-f0-9]{40}')
|
||||
const testResults = regex.test(checksum)
|
||||
const { checksum } = this.state;
|
||||
const regex = RegExp("[a-f0-9]{40}");
|
||||
const testResults = regex.test(checksum);
|
||||
|
||||
return testResults
|
||||
}
|
||||
return testResults;
|
||||
};
|
||||
|
||||
isInputInvalid = () => {
|
||||
const { checksum } = this.state
|
||||
if (!checksum || checksum === '') {
|
||||
return false
|
||||
const { checksum } = this.state;
|
||||
if (!checksum || checksum === "") {
|
||||
return false;
|
||||
}
|
||||
return !this.isChecksumValid()
|
||||
}
|
||||
return !this.isChecksumValid();
|
||||
};
|
||||
|
||||
addNotification = function (text, success = true) {
|
||||
store.addNotification({
|
||||
message: text,
|
||||
type: success ? 'success' : 'danger',
|
||||
type: success ? "success" : "danger",
|
||||
// type: "danger",
|
||||
insert: 'bottom-left',
|
||||
container: 'top-full',
|
||||
animationIn: ['animate__animated', 'animate__fadeIn'],
|
||||
animationOut: ['animate__animated', 'animate__fadeOut'],
|
||||
insert: "bottom-left",
|
||||
container: "top-full",
|
||||
animationIn: ["animate__animated", "animate__fadeIn"],
|
||||
animationOut: ["animate__animated", "animate__fadeOut"],
|
||||
dismiss: {
|
||||
duration: 3000,
|
||||
onScreen: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
requestActivationBytes = async (checksumX) => {
|
||||
const checksum = checksumX ? checksumX : this.state.checksum
|
||||
const checksum = checksumX ? checksumX : this.state.checksum;
|
||||
|
||||
window.history.pushState('page2', 'Title', '/' + checksum);
|
||||
|
||||
let executeRecaptcha = this.props.googleReCaptchaProps.executeRecaptcha
|
||||
window.history.pushState("page2", "Title", "/" + checksum);
|
||||
|
||||
while(!executeRecaptcha) {
|
||||
console.log('Recaptcha has not been loaded')
|
||||
executeRecaptcha = this.props.googleReCaptchaProps?.executeRecaptcha
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
let executeRecaptcha = this.props.googleReCaptchaProps.executeRecaptcha;
|
||||
|
||||
while (!executeRecaptcha) {
|
||||
console.log("Recaptcha has not been loaded");
|
||||
executeRecaptcha = this.props.googleReCaptchaProps?.executeRecaptcha;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
const token = await executeRecaptcha('homepage')
|
||||
console.log(`XToken: ${token}`)
|
||||
const token = await executeRecaptcha("homepage");
|
||||
console.log(`XToken: ${token}`);
|
||||
try {
|
||||
let request = await fetch(
|
||||
`${process.env.REACT_APP_APISERVER}/api/v2/activation/${checksum}`,
|
||||
{
|
||||
headers: new Headers({ 'x-captcha-result': token }),
|
||||
},
|
||||
)
|
||||
let result = await request.json()
|
||||
const { success, activationBytes } = result
|
||||
headers: new Headers({ "x-captcha-result": token }),
|
||||
}
|
||||
);
|
||||
let result = await request.json();
|
||||
const { success, activationBytes } = result;
|
||||
|
||||
if (success !== true) {
|
||||
this.setState({ activationBytes: 'UNKNOWN' })
|
||||
this.setState({ activationBytes: "UNKNOWN" });
|
||||
this.addNotification(
|
||||
'An error occured while resolving the activation bytes, please check your inputs',
|
||||
false,
|
||||
)
|
||||
return
|
||||
"An error occured while resolving the activation bytes, please check your inputs",
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (success === true) {
|
||||
const calculatedChecksum = await AaxHashAlgorithm.CalculateChecksum(
|
||||
activationBytes,
|
||||
)
|
||||
activationBytes
|
||||
);
|
||||
if (calculatedChecksum == checksum) {
|
||||
this.setState({ activationBytes: activationBytes })
|
||||
this.addNotification('Successfully resolved the activation bytes')
|
||||
return
|
||||
this.setState({ activationBytes: activationBytes });
|
||||
this.addNotification("Successfully resolved the activation bytes");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ activationBytes: 'API ERROR' })
|
||||
this.setState({ activationBytes: "API ERROR" });
|
||||
this.addNotification(
|
||||
'An unexpected error occured while resolving the activation bytes, please try again',
|
||||
false,
|
||||
)
|
||||
"An unexpected error occured while resolving the activation bytes, please try again",
|
||||
false
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState({ activationBytes: error })
|
||||
this.setState({ activationBytes: error });
|
||||
this.addNotification(
|
||||
'An error occured while resolving the activation bytes, please check your inputs',
|
||||
false,
|
||||
)
|
||||
"An error occured while resolving the activation bytes, please check your inputs",
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
buf2hex(buffer) {
|
||||
// buffer is an ArrayBuffer
|
||||
return Array.prototype.map
|
||||
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||
.join('')
|
||||
.call(new Uint8Array(buffer), (x) => ("00" + x.toString(16)).slice(-2))
|
||||
.join("");
|
||||
}
|
||||
|
||||
acceptFiles = async (files) => {
|
||||
const file = files[0]
|
||||
await this.acceptFile(file)
|
||||
}
|
||||
const file = files[0];
|
||||
await this.acceptFile(file);
|
||||
};
|
||||
|
||||
acceptFile = async (file) => {
|
||||
// if (!file.name.toLowerCase().endsWith(".aax")) {
|
||||
// alert('FileType not supported!');
|
||||
// return;
|
||||
// }
|
||||
if (!file.name.toLowerCase().endsWith(".aax")) {
|
||||
// alert('FileType not supported!');
|
||||
// return;
|
||||
// notify user that the file type is not supported
|
||||
// this.addNotification("FileType not supported!", false);
|
||||
|
||||
this.setState({ fileName: file.name, file: file })
|
||||
const slic = file.slice(653, 653 + 20)
|
||||
const results = this.buf2hex(await slic.arrayBuffer())
|
||||
this.setChecksum(results)
|
||||
this.requestActivationBytes()
|
||||
}
|
||||
// notify user that the file type is not supported with alert, ask if they want to continue
|
||||
|
||||
// Message if aaxc: "Only .aax files are supported, you have provided a .aaxc file. Renaming the file won't work, please provide a .aax file. You can download the .aax file from the Audible website. (eg: https://www.audible.de/library/titles)"
|
||||
|
||||
const fileExtension = file.name.includes(".") ? file.name.split(".").pop() : "unknown";
|
||||
const message = file.name.toLowerCase().endsWith(".aaxc")
|
||||
? "Only .aax files are supported, you have provided a .aaxc file. Renaming the file won't work, please provide a .aax file. You can download the .aax file from the Audible website. (eg: https://www.audible.de/library/titles) Do you want to continue anyway?"
|
||||
: `.${fileExtension} files are not supported! Do you want to continue anyway?`;
|
||||
|
||||
const response = window.confirm(message);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ fileName: file.name, file: file });
|
||||
const slic = file.slice(653, 653 + 20);
|
||||
const results = this.buf2hex(await slic.arrayBuffer());
|
||||
this.setChecksum(results);
|
||||
this.requestActivationBytes();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { classes } = this.props
|
||||
const { checksum, activationBytes, fileName, file } = this.state
|
||||
const { classes } = this.props;
|
||||
const { checksum, activationBytes, fileName, file } = this.state;
|
||||
// const id = this.props.match.params.id;
|
||||
// let { id } = useParams();
|
||||
// console.log("IDDDDDD"+ id);
|
||||
|
@ -254,8 +267,8 @@ class ChecksumResolver extends React.Component {
|
|||
<Dropzone
|
||||
noClick
|
||||
onDrop={(acceptedFiles) => {
|
||||
console.log(acceptedFiles)
|
||||
this.acceptFiles(acceptedFiles)
|
||||
console.log(acceptedFiles);
|
||||
this.acceptFiles(acceptedFiles);
|
||||
}}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
|
@ -280,7 +293,7 @@ class ChecksumResolver extends React.Component {
|
|||
readOnly: false,
|
||||
endAdornment: (
|
||||
<FilePicker
|
||||
extensions={['aax', 'AAX']}
|
||||
extensions={["aax", "AAX"]}
|
||||
maxSize={99999}
|
||||
onChange={this.acceptFile}
|
||||
onError={() => {}}
|
||||
|
@ -297,16 +310,38 @@ class ChecksumResolver extends React.Component {
|
|||
)}
|
||||
</Dropzone>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
this.requestActivationBytes()
|
||||
}}
|
||||
disabled={!this.isChecksumValid()}
|
||||
>
|
||||
Request Activation Bytes
|
||||
</Button>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
this.requestActivationBytes();
|
||||
}}
|
||||
disabled={!this.isChecksumValid()}
|
||||
>
|
||||
Request Activation Bytes
|
||||
</Button>
|
||||
|
||||
{/* Paypal donate Button redirects to https://www.paypal.com/paypalme/jdawg1337*/}
|
||||
<Button
|
||||
style={{ marginLeft: 8, minWidth: 137 }}
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
// window.location.href = "https://www.paypal.com/paypalme/jdawg1337";
|
||||
// open in new tab
|
||||
window.open("https://www.paypal.com/paypalme/jdawg1337", "_blank");
|
||||
}}
|
||||
>
|
||||
{/* paypal icon */}
|
||||
<a style={{ paddingLeft: 8, height: "100%" }}>Donate</a>
|
||||
<img
|
||||
style={{ marginLeft: -10, width: 50 }}
|
||||
// src="https://www.paypalobjects.com/webstatic/en_US/i/buttons/PP_logo_h_100x26.png"
|
||||
src="./paypal_PNG7.png"
|
||||
alt="PayPal"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<this.DarkerDisabledTextField
|
||||
value={activationBytes}
|
||||
|
@ -315,7 +350,7 @@ class ChecksumResolver extends React.Component {
|
|||
margin="normal"
|
||||
fullWidth
|
||||
id="activationBytes"
|
||||
label={activationBytes ? '' : 'Activation Bytes'}
|
||||
label={activationBytes ? "" : "Activation Bytes"}
|
||||
name="activationBytes"
|
||||
autoComplete="activationBytes"
|
||||
aria-readonly
|
||||
|
@ -341,9 +376,8 @@ class ChecksumResolver extends React.Component {
|
|||
<this.Copyright />
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withGoogleReCaptcha(withStyles(useStyles)(ChecksumResolver))
|
||||
|
||||
export default withGoogleReCaptcha(withStyles(useStyles)(ChecksumResolver));
|
||||
|
|
|
@ -48,7 +48,7 @@ async function WakeUp() {
|
|||
while (true) {
|
||||
let timeout = 1000 * 60; // 60 seconds
|
||||
try {
|
||||
await fetch('https://api.audible-converter.ml/api/v2/WakeUpNeo')
|
||||
await fetch('https://aaxapiserverfunction20220831180001.azurewebsites.net/api/v2/WakeUpNeo')
|
||||
console.log('Woke up')
|
||||
} catch (ex) {
|
||||
console.log('Error occured: ' + ex)
|
||||
|
|
Loading…
Add table
Reference in a new issue