Merge branch 'master' of https://dev.azure.com/alicorn-dev/KintoneAppBuilder/_git/KintoneAppBuilder
This commit is contained in:
Binary file not shown.
@@ -8,6 +8,141 @@ import app.core.config as c
|
|||||||
|
|
||||||
kinton_router = r = APIRouter()
|
kinton_router = r = APIRouter()
|
||||||
|
|
||||||
|
def getfieldsfromexcel(df):
|
||||||
|
appname = df.iloc[0,2]
|
||||||
|
col=[]
|
||||||
|
for row in range(5,len(df)):
|
||||||
|
if not df.iloc[row,3] in c.KINTONE_FIELD_TYPE:
|
||||||
|
continue
|
||||||
|
p=[]
|
||||||
|
for column in range(1,7):
|
||||||
|
if(not pd.isna(df.iloc[row,column])):
|
||||||
|
if(property[column-1]=="options"):
|
||||||
|
o=[]
|
||||||
|
for v in df.iloc[row,column].split(','):
|
||||||
|
o.append(f"\"{v.split('|')[0]}\":{{\"label\":\"{v.split('|')[0]}\",\"index\":\"{v.split('|')[1]}\"}}")
|
||||||
|
p.append(f"\"{property[column-1]}\":{{{','.join(o)}}}")
|
||||||
|
elif(property[column-1]=="required"):
|
||||||
|
p.append(f"\"{property[column-1]}\":{df.iloc[row,column]}")
|
||||||
|
else:
|
||||||
|
p.append(f"\"{property[column-1]}\":\"{df.iloc[row,column]}\"")
|
||||||
|
col.append(f"\"{df.iloc[row,2]}\":{{{','.join(p)}}}")
|
||||||
|
fields = ",".join(col).replace("False","false").replace("True","true")
|
||||||
|
return json.loads(f"{{{fields}}}")
|
||||||
|
|
||||||
|
def getsettingfromexcel(df):
|
||||||
|
appname = df.iloc[0,2]
|
||||||
|
des = df.iloc[2,2]
|
||||||
|
return {"name":appname,"description":des}
|
||||||
|
|
||||||
|
def getsettingfromkintone(app:str):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE}
|
||||||
|
params = {"app":app}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/app/settings.json"
|
||||||
|
r = httpx.get(url,headers=headers,params=params)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def analysesettings(excel,kintone):
|
||||||
|
updatesettings={}
|
||||||
|
updates = excel.keys() & kintone.keys()
|
||||||
|
for key in updates:
|
||||||
|
if excel[key] != kintone[key]:
|
||||||
|
updatesettings[key] = excel[key]
|
||||||
|
return updatesettings
|
||||||
|
|
||||||
|
def createkintoneapp(name:str):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
||||||
|
data = {"name":name}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app.json"
|
||||||
|
r = httpx.post(url,headers=headers,data=json.dumps(data))
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def updateappsettingstokintone(app:str,updates:dict):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/settings.json"
|
||||||
|
data = {"app":app}
|
||||||
|
data.update(updates)
|
||||||
|
r = httpx.put(url,headers=headers,data=json.dumps(data))
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def addfieldstokintone(app:str,fields:dict,revision:str = None):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/form/fields.json"
|
||||||
|
if revision != None:
|
||||||
|
data = {"app":app,"revision":revision,"properties":fields}
|
||||||
|
else:
|
||||||
|
data = {"app":app,"properties":fields}
|
||||||
|
r = httpx.post(url,headers=headers,data=json.dumps(data))
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def updatefieldstokintone(app:str,revision:str,fields:dict):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/form/fields.json"
|
||||||
|
data = {"app":app,"properties":fields}
|
||||||
|
r = httpx.put(url,headers=headers,data=json.dumps(data))
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def deletefieldsfromkintone(app:str,revision:str,fields:dict):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/form/fields.json"
|
||||||
|
params = {"app":app,"revision":revision,"fields":fields}
|
||||||
|
#r = httpx.delete(url,headers=headers,content=json.dumps(params))
|
||||||
|
r = httpx.request(method="DELETE",url=url,headers=headers,content=json.dumps(params))
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def deoployappfromkintone(app:str,revision:str):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/deploy.json"
|
||||||
|
data = {"apps":[{"app":app,"revision":revision}],"revert": False}
|
||||||
|
r = httpx.post(url,headers=headers,data=json.dumps(data))
|
||||||
|
return r.json
|
||||||
|
|
||||||
|
def getfieldsfromkintone(app):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE}
|
||||||
|
params = {"app":app}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/app/form/fields.json"
|
||||||
|
r = httpx.get(url,headers=headers,params=params)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def analysefields(excel,kintone):
|
||||||
|
updatefields={}
|
||||||
|
addfields={}
|
||||||
|
delfields=[]
|
||||||
|
updates = excel.keys() & kintone.keys()
|
||||||
|
adds = excel.keys() - kintone.keys()
|
||||||
|
dels = kintone.keys() - excel.keys()
|
||||||
|
for key in updates:
|
||||||
|
for p in property:
|
||||||
|
if excel[key].get(p) != None and kintone[key][p] != excel[key][p]:
|
||||||
|
updatefields[key] = excel[key]
|
||||||
|
break
|
||||||
|
for key in adds:
|
||||||
|
addfields[key] = excel[key]
|
||||||
|
for key in dels:
|
||||||
|
if kintone[key]["type"] in c.KINTONE_FIELD_TYPE:
|
||||||
|
delfields.append(key)
|
||||||
|
|
||||||
|
return {"update":updatefields,"add":addfields,"del":delfields}
|
||||||
|
|
||||||
|
|
||||||
|
@r.post("/test",)
|
||||||
|
async def test(file:UploadFile= File(...),app:str=None):
|
||||||
|
if file.filename.endswith('.xlsx'):
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
df = pd.read_excel(BytesIO(content))
|
||||||
|
excel = getfieldsfromexcel(df)
|
||||||
|
if(app != None):
|
||||||
|
kintone = getfieldsfromkintone(app)
|
||||||
|
fields = analysefields(excel,kintone["properties"])
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Error occurred while parsing file {file.filename}: {str(e)}")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"File {file.filename} is not an Excel file")
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
@r.post("/upload",)
|
@r.post("/upload",)
|
||||||
async def upload(files:t.List[UploadFile] = File(...)):
|
async def upload(files:t.List[UploadFile] = File(...)):
|
||||||
@@ -33,6 +168,18 @@ async def allapps():
|
|||||||
r = httpx.get(url,headers=headers)
|
r = httpx.get(url,headers=headers)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
@r.get("/appfields")
|
||||||
|
async def appfields(app:str):
|
||||||
|
return getfieldsfromkintone(app)
|
||||||
|
|
||||||
|
|
||||||
|
@r.get("/alljscs")
|
||||||
|
async def alljscs(app:str):
|
||||||
|
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE}
|
||||||
|
url = f"{c.BASE_URL}{c.API_V1_STR}/app/customize.json"
|
||||||
|
params = {"app":app}
|
||||||
|
r = httpx.get(url,headers=headers,params=params)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
@r.post("/createapp",)
|
@r.post("/createapp",)
|
||||||
async def createapp(name:str):
|
async def createapp(name:str):
|
||||||
@@ -55,97 +202,69 @@ async def createappfromexcel(files:t.List[UploadFile] = File(...)):
|
|||||||
if file.filename.endswith('.xlsx'):
|
if file.filename.endswith('.xlsx'):
|
||||||
try:
|
try:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
#アプリ名
|
|
||||||
df = pd.read_excel(BytesIO(content))
|
df = pd.read_excel(BytesIO(content))
|
||||||
# print(df)
|
#アプリ名
|
||||||
appname = df.iloc[0,2]
|
appname = df.iloc[0,2]
|
||||||
col=[]
|
desc = df.iloc[2,2]
|
||||||
for row in range(5,len(df)):
|
result = {"app":0,"revision":0,"msg":""}
|
||||||
if not df.iloc[row,3] in c.KINTONE_FIELD_TYPE:
|
fields = getfieldsfromexcel(df)
|
||||||
continue
|
app = createkintoneapp(appname)
|
||||||
p=[]
|
if app.get("app") != None:
|
||||||
for column in range(1,7):
|
result["app"] = app["app"]
|
||||||
if(not pd.isna(df.iloc[row,column])):
|
app = updateappsettingstokintone(result["app"],{"description":desc})
|
||||||
if(property[column-1]=="options"):
|
if app.get("revision") != None:
|
||||||
o=[]
|
result["revision"] = app["revision"]
|
||||||
for v in df.iloc[row,column].split(','):
|
app = addfieldstokintone(result["app"],fields)
|
||||||
o.append(f"\"{v.split('|')[0]}\":{{\"label\":\"{v.split('|')[0]}\",\"index\":\"{v.split('|')[1]}\"}}")
|
if app.get("revision") != None:
|
||||||
p.append(f"\"{property[column-1]}\":{{{','.join(o)}}}")
|
result["revision"] = app["revision"]
|
||||||
elif(property[column-1]=="required"):
|
deoployappfromkintone(result["app"],result["revision"])
|
||||||
p.append(f"\"{property[column-1]}\":{df.iloc[row,column]}")
|
|
||||||
else:
|
|
||||||
p.append(f"\"{property[column-1]}\":\"{df.iloc[row,column]}\"")
|
|
||||||
col.append(f"\"{df.iloc[row,2]}\":{{{','.join(p)}}}")
|
|
||||||
|
|
||||||
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
|
||||||
data = {"name":appname}
|
|
||||||
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app.json"
|
|
||||||
r = httpx.post(url,headers=headers,data=json.dumps(data))
|
|
||||||
result1 = r.json()
|
|
||||||
if result1.get("app") != None:
|
|
||||||
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/form/fields.json"
|
|
||||||
form = f"{{\"app\":{result1['app']},\"properties\":{{{','.join(col)}}}}}".replace("False","false").replace("True","true")
|
|
||||||
print(form)
|
|
||||||
data = json.loads(form)
|
|
||||||
r = httpx.post(url,headers=headers,data=json.dumps(data))
|
|
||||||
result2 = r.json()
|
|
||||||
if result2.get("revision") != None:
|
|
||||||
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/deploy.json"
|
|
||||||
data = {"apps":[{"app":result1["app"],"revision":result2["revision"]}],"revert": False}
|
|
||||||
r = httpx.post(url,headers=headers,data=json.dumps(data))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Error occurred while parsing file {file.filename}: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Error occurred while parsing file {file.filename}: {str(e)}")
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"File {file.filename} is not an Excel file")
|
raise HTTPException(status_code=400, detail=f"File {file.filename} is not an Excel file")
|
||||||
|
|
||||||
return {"app":result1["app"],"revision":result2["revision"]}
|
return result
|
||||||
|
|
||||||
|
|
||||||
@r.post("/updateappfromexcel",)
|
@r.post("/updateappfromexcel",)
|
||||||
async def updateappfromexcel(app:str,revision:str,files:t.List[UploadFile] = File(...)):
|
async def updateappfromexcel(app:str,files:t.List[UploadFile] = File(...)):
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.filename.endswith('.xlsx'):
|
if file.filename.endswith('.xlsx'):
|
||||||
try:
|
try:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
#アプリ名
|
|
||||||
df = pd.read_excel(BytesIO(content))
|
df = pd.read_excel(BytesIO(content))
|
||||||
# print(df)
|
excel = getsettingfromexcel(df)
|
||||||
appname = df.iloc[0,2]
|
kintone= getsettingfromkintone(app)
|
||||||
col=[]
|
settings = analysesettings(excel,kintone)
|
||||||
for row in range(5,len(df)):
|
excel = getfieldsfromexcel(df)
|
||||||
if not df.iloc[row,3] in c.KINTONE_FIELD_TYPE:
|
kintone = getfieldsfromkintone(app)
|
||||||
continue
|
revision = kintone["revision"]
|
||||||
p=[]
|
fields = analysefields(excel,kintone["properties"])
|
||||||
for column in range(1,7):
|
result = {"app":app,"revision":revision,"msg":"No Update"}
|
||||||
if(not pd.isna(df.iloc[row,column])):
|
deploy = False
|
||||||
if(property[column-1]=="options"):
|
if len(fields["update"]) > 0:
|
||||||
o=[]
|
result = updatefieldstokintone(app,revision,fields["update"])
|
||||||
for v in df.iloc[row,column].split(','):
|
revision = result["revision"]
|
||||||
o.append(f"\"{v.split('|')[0]}\":{{\"label\":\"{v.split('|')[0]}\",\"index\":\"{v.split('|')[1]}\"}}")
|
deploy = True
|
||||||
p.append(f"\"{property[column-1]}\":{{{','.join(o)}}}")
|
if len(fields["add"]) > 0:
|
||||||
elif(property[column-1]=="required"):
|
result = addfieldstokintone(app,fields["add"],revision)
|
||||||
p.append(f"\"{property[column-1]}\":{df.iloc[row,column]}")
|
revision = result["revision"]
|
||||||
else:
|
deploy = True
|
||||||
p.append(f"\"{property[column-1]}\":\"{df.iloc[row,column]}\"")
|
if len(fields["del"]) > 0:
|
||||||
col.append(f"\"{df.iloc[row,2]}\":{{{','.join(p)}}}")
|
result = deletefieldsfromkintone(app,revision,fields["del"])
|
||||||
|
revision = result["revision"]
|
||||||
headers={c.API_V1_AUTH_KEY:c.API_V1_AUTH_VALUE,"Content-Type": "application/json"}
|
deploy = True
|
||||||
|
if len(settings) > 0:
|
||||||
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/form/fields.json"
|
result = updateappsettingstokintone(app,settings)
|
||||||
form = f"{{\"app\":{app},\"revision\": {revision},\"properties\":{{{','.join(col)}}}}}".replace("False","false").replace("True","true")
|
revision = result["revision"]
|
||||||
print(form)
|
deploy = True
|
||||||
data = json.loads(form)
|
if deploy:
|
||||||
r = httpx.put(url,headers=headers,data=json.dumps(data))
|
result = deoployappfromkintone(app,revision)
|
||||||
result = r.json()
|
|
||||||
if result.get("revision") != None:
|
|
||||||
url = f"{c.BASE_URL}{c.API_V1_STR}/preview/app/deploy.json"
|
|
||||||
data = {"apps":[{"app":app,"revision":result["revision"]}],"revert": False}
|
|
||||||
r = httpx.post(url,headers=headers,data=json.dumps(data))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Error occurred while parsing file {file.filename}: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Error occurred while parsing file {file.filename}: {str(e)}")
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"File {file.filename} is not an Excel file")
|
raise HTTPException(status_code=400, detail=f"File {file.filename} is not an Excel file")
|
||||||
|
|
||||||
return r.json()
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ API_V1_AUTH_KEY = "X-Cybozu-Authorization"
|
|||||||
|
|
||||||
API_V1_AUTH_VALUE = "TVhaOm1heHoxMjA1"
|
API_V1_AUTH_VALUE = "TVhaOm1heHoxMjA1"
|
||||||
|
|
||||||
KINTONE_FIELD_TYPE=["GROUP","GROUP_SELECT","CHECK_BOX","SUBTABLE","RICH_TEXT","RICH_TEXT","LINK","REFERENCE_TABLE","CALC","TIME","NUMBER","ORGANIZATION_SELECT","FILE","DATETIME","DATE","MULTI_SELECT","SINGLE_LINE_TEXT","MULTI_LINE_TEXT"]
|
KINTONE_FIELD_TYPE=["GROUP","GROUP_SELECT","CHECK_BOX","SUBTABLE","DROP_DOWN","USER_SELECT","RADIO_BUTTON","RICH_TEXT","LINK","REFERENCE_TABLE","CALC","TIME","NUMBER","ORGANIZATION_SELECT","FILE","DATETIME","DATE","MULTI_SELECT","SINGLE_LINE_TEXT","MULTI_LINE_TEXT"]
|
||||||
@@ -50,6 +50,19 @@ import { ref } from 'vue';
|
|||||||
import EssentialLink, { EssentialLinkProps } from 'components/EssentialLink.vue';
|
import EssentialLink, { EssentialLinkProps } from 'components/EssentialLink.vue';
|
||||||
|
|
||||||
const essentialLinks: EssentialLinkProps[] = [
|
const essentialLinks: EssentialLinkProps[] = [
|
||||||
|
{
|
||||||
|
title: 'ホーム',
|
||||||
|
caption: 'home',
|
||||||
|
icon: 'home',
|
||||||
|
link: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ルールエディター',
|
||||||
|
caption: 'rule',
|
||||||
|
icon: 'rule',
|
||||||
|
link: '/#/ruleEditor'
|
||||||
|
}
|
||||||
|
,
|
||||||
{
|
{
|
||||||
title: 'Docs',
|
title: 'Docs',
|
||||||
caption: 'quasar.dev',
|
caption: 'quasar.dev',
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page>
|
<q-page>
|
||||||
|
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
|
<div class="q-gutter-sm row items-start">
|
||||||
|
<q-breadcrumbs>
|
||||||
|
<q-breadcrumbs-el icon="home" label="ホーム" />
|
||||||
|
</q-breadcrumbs>
|
||||||
|
</div>
|
||||||
<div class="q-gutter-sm row items-start">
|
<div class="q-gutter-sm row items-start">
|
||||||
<doc-uploader></doc-uploader>
|
<doc-uploader></doc-uploader>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,16 +2,48 @@
|
|||||||
<q-page>
|
<q-page>
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
<div class="q-gutter-sm row items-start">
|
<div class="q-gutter-sm row items-start">
|
||||||
|
<q-breadcrumbs>
|
||||||
|
<q-breadcrumbs-el icon="home" to="/" />
|
||||||
|
<q-breadcrumbs-el :label="title" icon="rule" />
|
||||||
|
</q-breadcrumbs>
|
||||||
|
</div>
|
||||||
|
<div id="q-app" style="min-height: 100vh;">
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<q-btn-dropdown
|
||||||
|
split
|
||||||
|
color="primary"
|
||||||
|
label="ルール新規作成"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<q-list>
|
||||||
|
<q-item
|
||||||
|
v-for="action in actions"
|
||||||
|
clickable v-close-popup
|
||||||
|
@click="onItemClick"
|
||||||
|
:key="action"
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ action }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Todo, Meta } from 'components/models';
|
interface Props {
|
||||||
import DocUploader from 'components/DocUpload.vue';
|
title: string;
|
||||||
// import ExampleComponent from 'components/ExampleComponent.vue';
|
actions:string[];
|
||||||
import { ref } from 'vue';
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title:"ルールエディター",
|
||||||
|
actions:()=>["フィールド制御","一覧画面","その他"]
|
||||||
|
});
|
||||||
|
function onItemClick(evt: Event){
|
||||||
|
return;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('layouts/MainLayout.vue'),
|
component: () => import('layouts/MainLayout.vue'),
|
||||||
children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
|
children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/ruleEditor/',
|
||||||
|
component: () => import('layouts/MainLayout.vue'),
|
||||||
|
children: [{ path: '', component: () => import('pages/RuleEditor.vue') }],
|
||||||
|
},
|
||||||
|
|
||||||
// Always leave this as last one,
|
// Always leave this as last one,
|
||||||
// but you can also remove it
|
// but you can also remove it
|
||||||
|
|||||||
Reference in New Issue
Block a user