initial commit
This commit is contained in:
83
src/graphql.ts
Normal file
83
src/graphql.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import {
|
||||
Application,
|
||||
Router,
|
||||
RouterContext,
|
||||
graphql,
|
||||
makeExecutableSchema,
|
||||
} from "./mods";
|
||||
|
||||
export interface ResolversProps {
|
||||
Query?: any;
|
||||
Mutation?: any;
|
||||
[dynamicProperty: string]: any;
|
||||
}
|
||||
|
||||
export interface ApplyGraphQLOptions {
|
||||
app: Application;
|
||||
path?: string;
|
||||
typeDefs: any;
|
||||
resolvers: ResolversProps;
|
||||
context?: (ctx: RouterContext) => any;
|
||||
}
|
||||
|
||||
export const applyGraphQL = ({
|
||||
app,
|
||||
path = "/graphql",
|
||||
typeDefs,
|
||||
resolvers,
|
||||
context,
|
||||
}: ApplyGraphQLOptions) => {
|
||||
const schema = makeExecutableSchema({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
logger: {
|
||||
log: (err: any) => console.log(err),
|
||||
},
|
||||
});
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(path, async (ctx) => {
|
||||
const { response, request } = ctx;
|
||||
|
||||
if (!request.is("application/json") || !request.body) {
|
||||
response.status = 415;
|
||||
response.body = {
|
||||
error: { message: "Request body must be in json format." },
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const contextResult = context ? await context(ctx) : ctx;
|
||||
const { query, variables, operationName } = request.body;
|
||||
|
||||
try {
|
||||
if (!query) {
|
||||
response.status = 422;
|
||||
response.body = {
|
||||
error: { message: "Body missing 'query' parameter." },
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const result: any = await graphql(
|
||||
schema,
|
||||
query,
|
||||
resolvers,
|
||||
contextResult,
|
||||
variables,
|
||||
operationName
|
||||
);
|
||||
|
||||
response.body = result;
|
||||
} catch (error) {
|
||||
response.status = 500;
|
||||
response.body = {
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
};
|
6
src/graphql/index.ts
Normal file
6
src/graphql/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { mergeTypeDefs, mergeResolvers } from "../mods";
|
||||
|
||||
const modules = [require("./sudoku")];
|
||||
|
||||
export const typeDefs = mergeTypeDefs(modules.map((mod) => mod.typeDefs));
|
||||
export const resolvers = mergeResolvers(modules.map((mod) => mod.resolvers));
|
55
src/graphql/sudoku.ts
Normal file
55
src/graphql/sudoku.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { gql } from "../mods";
|
||||
import { generate } from "../sudoku/index";
|
||||
|
||||
export const typeDefs = gql`
|
||||
type Cell {
|
||||
value: Int
|
||||
}
|
||||
"""
|
||||
A sudoku
|
||||
"""
|
||||
type Sudoku {
|
||||
"The width of each region."
|
||||
regionWidth: Int!
|
||||
|
||||
"The height of each region."
|
||||
regionHeight: Int!
|
||||
|
||||
"The total number of cells in the board."
|
||||
cells: Int!
|
||||
|
||||
"The 'raw' board, an array of cells in region-first order."
|
||||
raw: [Cell!]!
|
||||
|
||||
"The rows of the board, from top to bottom."
|
||||
rows: [[Cell!]!]!
|
||||
|
||||
"The columns of the board, from left to right."
|
||||
columns: [[Cell!]!]!
|
||||
|
||||
"The regions of the board, book-ordered."
|
||||
regions: [[Cell!]!]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
"Generates a new sudoku."
|
||||
generate(regionWidth: Int = 3, regionHeight: Int = 3, clues: Int!): Sudoku!
|
||||
}
|
||||
`;
|
||||
|
||||
type GenerateArguments = {
|
||||
regionWidth: number;
|
||||
regionHeight: number;
|
||||
clues: number;
|
||||
};
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
generate: (
|
||||
obj: any,
|
||||
{ regionWidth, regionHeight, clues }: GenerateArguments
|
||||
) => {
|
||||
return generate(regionWidth, regionHeight, clues);
|
||||
},
|
||||
},
|
||||
};
|
69
src/main.ts
Normal file
69
src/main.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { Application, bodyParser } from "./mods";
|
||||
import { applyGraphQL } from "./graphql";
|
||||
import { typeDefs, resolvers } from "./graphql/index";
|
||||
import stoppable from "stoppable";
|
||||
|
||||
const runtime: { server: undefined | stoppable.StoppableServer } = {
|
||||
server: undefined,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const app = new Application();
|
||||
app.use(
|
||||
bodyParser({
|
||||
enableTypes: ["json"],
|
||||
})
|
||||
);
|
||||
|
||||
app.use(async (ctx, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
console.log(
|
||||
`\x1b[32m%s\x1b[0m %s \x1b[32m%dms\x1b[0m\n---`,
|
||||
ctx.request.method,
|
||||
ctx.request.url,
|
||||
Date.now() - start
|
||||
);
|
||||
});
|
||||
|
||||
applyGraphQL({
|
||||
app,
|
||||
typeDefs: typeDefs,
|
||||
resolvers: resolvers,
|
||||
});
|
||||
|
||||
runtime.server = stoppable(
|
||||
app.listen({ port: parseInt(process.env.LISTEN_PORT!) || 4000 }, () => {
|
||||
console.log(
|
||||
`Server listening at http://localhost:${
|
||||
process.env.LISTEN_PORT || 4000
|
||||
}\n---`
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
["SIGINT", "SIGTERM"].forEach((sig) => {
|
||||
process.on(sig, async () => {
|
||||
console.log("\nCaught", sig);
|
||||
console.log("Shutting down...");
|
||||
|
||||
if (runtime.server) {
|
||||
runtime.server.stop((error, gracefully) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
process.exit(2);
|
||||
} else if (!gracefully) {
|
||||
console.warn("Server was not shut down gracefully.");
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
process.exit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
main();
|
20
src/mods.ts
Normal file
20
src/mods.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// Koa
|
||||
import Application from "koa";
|
||||
export { Application };
|
||||
export { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
export { Router };
|
||||
export { RouterContext } from "koa-router";
|
||||
import bodyParser from "koa-bodyparser";
|
||||
export { bodyParser };
|
||||
|
||||
// graphql
|
||||
export { graphql, GraphQLScalarType } from "graphql";
|
||||
|
||||
// graphql-tag
|
||||
import gql from "graphql-tag";
|
||||
export { gql };
|
||||
|
||||
// graphql-tools
|
||||
export { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
export { mergeTypeDefs, mergeResolvers } from "@graphql-tools/merge";
|
37
src/sudoku/index.ts
Normal file
37
src/sudoku/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { SudokuMath } from "./math";
|
||||
|
||||
export type Cell = { value: number | null };
|
||||
export type CellArray = Cell[];
|
||||
export type Row = CellArray;
|
||||
export type Column = CellArray;
|
||||
export type Region = CellArray;
|
||||
export type Puzzle = CellArray;
|
||||
|
||||
export type Sudoku = {
|
||||
regionWidth: number;
|
||||
regionHeight: number;
|
||||
cells: number;
|
||||
raw: Puzzle;
|
||||
rows: () => Row[];
|
||||
columns: () => Column[];
|
||||
regions: () => Region[];
|
||||
};
|
||||
|
||||
export function generate(
|
||||
regionWidth: number,
|
||||
regionHeight: number,
|
||||
clues: number
|
||||
): Sudoku {
|
||||
const math = new SudokuMath(regionWidth, regionHeight);
|
||||
const puzzle = math.generatePuzzle(clues);
|
||||
|
||||
return {
|
||||
regionWidth,
|
||||
regionHeight,
|
||||
cells: math.boardCells,
|
||||
raw: puzzle,
|
||||
rows: () => math.regionsToRows(puzzle, true),
|
||||
columns: () => math.regionsToCols(puzzle, true),
|
||||
regions: () => math.chunkRegions(puzzle),
|
||||
};
|
||||
}
|
327
src/sudoku/math.ts
Normal file
327
src/sudoku/math.ts
Normal file
@ -0,0 +1,327 @@
|
||||
import { Cell, Row, Column, Region, Puzzle } from "./index";
|
||||
import {
|
||||
clone,
|
||||
range,
|
||||
shuffle,
|
||||
chunkify,
|
||||
mapArray,
|
||||
addCellValuesToSet,
|
||||
} from "./util";
|
||||
|
||||
function getTakenValues(region: Region, row: Row, col: Column) {
|
||||
const filter = new Set<number>();
|
||||
addCellValuesToSet(filter, region);
|
||||
addCellValuesToSet(filter, row);
|
||||
addCellValuesToSet(filter, col);
|
||||
return filter;
|
||||
}
|
||||
|
||||
export class SudokuMath {
|
||||
regionWidth: number;
|
||||
regionHeight: number;
|
||||
boardWidth: number;
|
||||
boardHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
boardCells: number;
|
||||
regionCells: number;
|
||||
legalValues: number[];
|
||||
|
||||
_regionsFromIndex: number[];
|
||||
_rowsFromIndex: number[];
|
||||
_colsFromIndex: number[];
|
||||
_rowIndexToRegionIndex: number[];
|
||||
_colIndexToRegionIndex: number[];
|
||||
_regionIndexToRowIndex: number[];
|
||||
_regionIndexToColIndex: number[];
|
||||
|
||||
constructor(regionWidth: number, regionHeight: number) {
|
||||
this.regionWidth = regionWidth;
|
||||
this.regionHeight = regionHeight;
|
||||
this.boardWidth = regionHeight;
|
||||
this.boardHeight = regionWidth;
|
||||
this.width = regionWidth * this.boardWidth;
|
||||
this.height = regionHeight * this.boardHeight;
|
||||
this.boardCells = this.width * this.height;
|
||||
this.regionCells = regionWidth * regionHeight;
|
||||
this.legalValues = range(1, this.regionCells);
|
||||
|
||||
this._regionsFromIndex = Array(this.boardCells);
|
||||
this._rowsFromIndex = Array(this.boardCells);
|
||||
this._colsFromIndex = Array(this.boardCells);
|
||||
this._rowIndexToRegionIndex = Array(this.boardCells);
|
||||
this._colIndexToRegionIndex = Array(this.boardCells);
|
||||
this._regionIndexToRowIndex = Array(this.boardCells);
|
||||
this._regionIndexToColIndex = Array(this.boardCells);
|
||||
|
||||
for (let i = 0; i < this.boardCells; i++) {
|
||||
this._regionsFromIndex[i] = this._regionFromRegionIndex(i);
|
||||
|
||||
const [row, col] = this._rowColFromRegionIndex(i);
|
||||
this._rowsFromIndex[i] = row;
|
||||
this._colsFromIndex[i] = col;
|
||||
|
||||
const rowIndex = row * this.width + col;
|
||||
const colIndex = col * this.height + row;
|
||||
this._rowIndexToRegionIndex[rowIndex] = i;
|
||||
this._colIndexToRegionIndex[i] = rowIndex;
|
||||
this._regionIndexToRowIndex[i] = rowIndex;
|
||||
this._regionIndexToColIndex[colIndex] = i;
|
||||
}
|
||||
}
|
||||
|
||||
_regionFromRegionIndex(i: number) {
|
||||
return Math.trunc(i / this.regionCells);
|
||||
}
|
||||
|
||||
regionFromRegionIndex(i: number) {
|
||||
return this._regionsFromIndex[i];
|
||||
}
|
||||
|
||||
_rowColFromRegionIndex(i: number) {
|
||||
const region = this.regionFromRegionIndex(i);
|
||||
const cell = i % this.regionCells;
|
||||
const regionRow = Math.trunc(region / this.boardWidth);
|
||||
const regionCol = region % this.boardWidth;
|
||||
const cellRow = Math.trunc(cell / this.regionWidth);
|
||||
const cellCol = cell % this.regionWidth;
|
||||
return [
|
||||
regionRow * this.regionHeight + cellRow,
|
||||
regionCol * this.regionWidth + cellCol,
|
||||
];
|
||||
}
|
||||
|
||||
rowColFromRegionIndex(i: number) {
|
||||
return [this._rowsFromIndex[i], this._colsFromIndex[i]];
|
||||
}
|
||||
|
||||
regionIndexToRowIndex(i: number) {
|
||||
return this._regionIndexToRowIndex[i];
|
||||
}
|
||||
|
||||
rowIndexToRegionIndex(i: number) {
|
||||
return this._rowIndexToRegionIndex[i];
|
||||
}
|
||||
|
||||
regionIndexToColIndex(i: number) {
|
||||
return this._regionIndexToColIndex[i];
|
||||
}
|
||||
|
||||
colIndexToRegionIndex(i: number) {
|
||||
return this._colIndexToRegionIndex[i];
|
||||
}
|
||||
|
||||
chunkRegions(cells: Cell[]) {
|
||||
return chunkify(cells, this.regionCells);
|
||||
}
|
||||
|
||||
regionsToRows(cells: Cell[], split = false) {
|
||||
const rows = mapArray(cells, this._regionIndexToRowIndex);
|
||||
return split ? chunkify(rows, this.width) : rows;
|
||||
}
|
||||
|
||||
regionsToCols(cells: Cell[], split = false) {
|
||||
const cols = mapArray(cells, this._regionIndexToColIndex);
|
||||
return split ? chunkify(cols, this.height) : cols;
|
||||
}
|
||||
|
||||
rowsToRegions(cells: Cell[], split = false) {
|
||||
const regions = mapArray(cells, this._rowIndexToRegionIndex);
|
||||
return split ? chunkify(regions, this.regionCells) : regions;
|
||||
}
|
||||
|
||||
colsToRegions(cells: Cell[], split = false) {
|
||||
const regions = mapArray(cells, this._colIndexToRegionIndex);
|
||||
return split ? chunkify(regions, this.regionCells) : regions;
|
||||
}
|
||||
|
||||
getBlankPuzzle(): Puzzle {
|
||||
return Array(this.boardCells)
|
||||
.fill(null)
|
||||
.map((value) => ({ value }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the remaining legal values given a set of taken values.
|
||||
*
|
||||
* @param taken a set of taken values
|
||||
*/
|
||||
getLegalValues(taken: Set<number>) {
|
||||
return this.legalValues.filter((value) => !taken.has(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which values are unavailable at a given location, looking at the
|
||||
* row, column, and region of the given cell index.
|
||||
*
|
||||
* @param cell
|
||||
* @param puzzle
|
||||
* @param regions
|
||||
*/
|
||||
getTakenValues(cell: number, puzzle: Puzzle, regions: Region[]) {
|
||||
const rows = this.regionsToRows(puzzle, true);
|
||||
const cols = this.regionsToCols(puzzle, true);
|
||||
const [row, col] = this.rowColFromRegionIndex(cell);
|
||||
const region = this.regionFromRegionIndex(cell);
|
||||
return getTakenValues(regions[region], rows[row], cols[col]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a puzzle has only one solution.
|
||||
*
|
||||
* @param puzzle
|
||||
*/
|
||||
hasOneSolution(puzzle: Cell[]) {
|
||||
const optimistic = clone(puzzle);
|
||||
if (this.optimisticSolver(optimistic)) {
|
||||
return true;
|
||||
}
|
||||
return this.backTrackingSolver(optimistic, 2) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a single-solution sudoku puzzle with
|
||||
*
|
||||
* @param clues the number of cells to have pre-filled
|
||||
*/
|
||||
generatePuzzle(clues: number) {
|
||||
const puzzle = this.getBlankPuzzle();
|
||||
this.backTrackingSolver(puzzle, 1, shuffle);
|
||||
|
||||
if (clues === -1 || clues >= puzzle.length) return puzzle;
|
||||
|
||||
const orig = clone(puzzle);
|
||||
|
||||
const toRemove = puzzle.length - clues;
|
||||
let removed: number[] = [];
|
||||
let removeNext: number[] = shuffle(puzzle.map((_, i) => i));
|
||||
|
||||
const remove = () => {
|
||||
const x = removeNext.shift() as any;
|
||||
removed.push(x);
|
||||
puzzle[x].value = null;
|
||||
};
|
||||
|
||||
const replace = () => {
|
||||
const x = removed.pop() as any;
|
||||
removeNext.push(x);
|
||||
puzzle[x].value = orig[x].value;
|
||||
};
|
||||
|
||||
const removeCell = () => {
|
||||
remove();
|
||||
if (this.hasOneSolution(puzzle)) {
|
||||
return true;
|
||||
}
|
||||
replace();
|
||||
return false;
|
||||
};
|
||||
|
||||
let fails = 0;
|
||||
while (removed.length < toRemove) {
|
||||
if (!removeCell()) {
|
||||
fails++;
|
||||
} else {
|
||||
console.log(`Removed ${removed.length} cells.`);
|
||||
fails = 0;
|
||||
}
|
||||
if (fails > removeNext.length) {
|
||||
fails = 0;
|
||||
console.log("Backstepping..");
|
||||
Array(removed.length)
|
||||
.fill(null)
|
||||
.forEach(() => replace());
|
||||
shuffle(removeNext);
|
||||
}
|
||||
}
|
||||
|
||||
return puzzle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to solve the puzzle "optimistically". Only sets values which are
|
||||
* certain, i.e. no guesses are made.
|
||||
*
|
||||
* Useful as a first pass.
|
||||
*
|
||||
* @param puzzle a region-ordered array of cells (each cell an object with
|
||||
* a `value` key.
|
||||
* @returns whether the puzzle was completely solved
|
||||
*/
|
||||
optimisticSolver(puzzle: Puzzle) {
|
||||
const regions = this.chunkRegions(puzzle);
|
||||
const rows = this.regionsToRows(puzzle, true);
|
||||
const cols = this.regionsToCols(puzzle, true);
|
||||
|
||||
const solve = (): boolean => {
|
||||
let foundValue = false;
|
||||
let foundEmpty = false;
|
||||
|
||||
for (let i = 0, len = puzzle.length; i < len; i++) {
|
||||
const cell = puzzle[i];
|
||||
if (!!cell.value) continue;
|
||||
foundEmpty = true;
|
||||
const region = this.regionFromRegionIndex(i);
|
||||
const [row, col] = this.rowColFromRegionIndex(i);
|
||||
const taken = getTakenValues(regions[region], rows[row], cols[col]);
|
||||
if (taken.size === this.regionCells - 1) {
|
||||
cell.value = this.getLegalValues(taken)[0];
|
||||
foundValue = true;
|
||||
}
|
||||
}
|
||||
return foundValue && foundEmpty ? solve() : !foundEmpty;
|
||||
};
|
||||
|
||||
return solve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backtracking solver. Mutates the puzzle during solve but eventually returns
|
||||
* it to its initial state.
|
||||
*
|
||||
* @param puzzle see optimisticSolver
|
||||
* @param stopAfter stop looking after this many solutions
|
||||
* @param guessStrategy a function which takes an array of possible
|
||||
* values for a cell, and returns the same values (in any order)
|
||||
* @returns the number of solutions found
|
||||
*/
|
||||
backTrackingSolver(
|
||||
puzzle: Puzzle,
|
||||
stopAfter: number = -1,
|
||||
guessStrategy: (values: number[]) => number[] = (values: number[]) => values
|
||||
) {
|
||||
const regions = this.chunkRegions(puzzle);
|
||||
const rows = this.regionsToRows(puzzle, true);
|
||||
const cols = this.regionsToCols(puzzle, true);
|
||||
|
||||
let solutions = 0;
|
||||
const solve = (): boolean => {
|
||||
for (let i = 0, len = puzzle.length; i < len; i++) {
|
||||
const cell = puzzle[i];
|
||||
if (!cell.value) {
|
||||
const region = this.regionFromRegionIndex(i);
|
||||
const [row, col] = this.rowColFromRegionIndex(i);
|
||||
const avail = guessStrategy(
|
||||
this.getLegalValues(
|
||||
getTakenValues(regions[region], rows[row], cols[col])
|
||||
)
|
||||
);
|
||||
for (let j = 0; j < avail.length; j++) {
|
||||
cell.value = avail[j];
|
||||
if (solve() && solutions === stopAfter) {
|
||||
return true;
|
||||
}
|
||||
cell.value = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
solutions++;
|
||||
return true;
|
||||
};
|
||||
|
||||
solve();
|
||||
|
||||
return solutions;
|
||||
}
|
||||
}
|
59
src/sudoku/util.ts
Normal file
59
src/sudoku/util.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Cell } from "./index";
|
||||
|
||||
function randInt(lower: number, upper: number) {
|
||||
return lower + Math.trunc(Math.random() * (upper - lower + 1));
|
||||
}
|
||||
|
||||
export function shuffle(arr: any[]) {
|
||||
const length = arr.length;
|
||||
let lastIndex = length - 1;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const rand = randInt(i, lastIndex);
|
||||
const tmp = arr[rand];
|
||||
|
||||
arr[rand] = arr[i];
|
||||
arr[i] = tmp;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function chunkify(arr: any[], chunkSize: number) {
|
||||
const chunks = Array(arr.length / chunkSize);
|
||||
for (let i = 0, len = chunks.length; i < len; i++) {
|
||||
const start = i * chunkSize;
|
||||
chunks[i] = arr.slice(start, start + chunkSize);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function range(start: number, end: number) {
|
||||
return Array(1 + end - start)
|
||||
.fill(null)
|
||||
.map((_, i) => start + i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-orders an array. Given an array of elements, return a new array of those
|
||||
* elements ordered by the indexes array.
|
||||
*
|
||||
* @param arr
|
||||
* @param indexes
|
||||
*/
|
||||
export function mapArray(arr: any[], indexes: number[]) {
|
||||
const newArr = Array(indexes.length);
|
||||
for (let i = 0, len = arr.length; i < len; i++) {
|
||||
newArr[i] = arr[indexes[i]];
|
||||
}
|
||||
return newArr;
|
||||
}
|
||||
|
||||
export function clone(puzz: Cell[]): Cell[] {
|
||||
return puzz.map(({ value }) => ({ value }));
|
||||
}
|
||||
|
||||
export function addCellValuesToSet(set: Set<number>, cells: Cell[]) {
|
||||
cells.forEach((cell) => {
|
||||
if (!!cell.value) set.add(cell.value);
|
||||
});
|
||||
return set;
|
||||
}
|
0
src/utils.ts
Normal file
0
src/utils.ts
Normal file
Reference in New Issue
Block a user