mirror of
https://github.com/Card-Forge/forge.git
synced 2025-11-19 04:08:01 +00:00
analyze token and icon directories
This commit is contained in:
@@ -321,7 +321,7 @@ http://www.cardforge.org/fpics/questAvatars/Savra.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Selesnya-precon.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Simic-precon.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Sisters%20of%20Stone%20Death.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Sus Antigoon.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Sus%20Antigoon.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Szadek.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Teysa.jpg
|
||||
http://www.cardforge.org/fpics/questAvatars/Token.jpg
|
||||
|
||||
@@ -47,6 +47,7 @@ import net.miginfocom.swing.MigLayout;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
|
||||
import forge.error.BugReporter;
|
||||
import forge.gui.MigrationSourceAnalyzer.OpType;
|
||||
import forge.gui.toolbox.FButton;
|
||||
import forge.gui.toolbox.FCheckBox;
|
||||
@@ -345,57 +346,75 @@ public class DialogMigrateProfile {
|
||||
|
||||
@Override
|
||||
protected Void doInBackground() throws Exception {
|
||||
Map<OpType, Map<File, File>> selections = new HashMap<OpType, Map<File, File>>();
|
||||
for (Map.Entry<OpType, Pair<FCheckBox, ? extends Map<File, File>>> entry : _selections.entrySet()) {
|
||||
selections.put(entry.getKey(), entry.getValue().getRight());
|
||||
}
|
||||
Timer timer = null;
|
||||
|
||||
MigrationSourceAnalyzer.AnalysisCallback cb = new MigrationSourceAnalyzer.AnalysisCallback() {
|
||||
@Override
|
||||
public boolean checkCancel() { return _cancel; }
|
||||
try {
|
||||
Map<OpType, Map<File, File>> selections = new HashMap<OpType, Map<File, File>>();
|
||||
for (Map.Entry<OpType, Pair<FCheckBox, ? extends Map<File, File>>> entry : _selections.entrySet()) {
|
||||
selections.put(entry.getKey(), entry.getValue().getRight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addOp(OpType type, File src, File dest) {
|
||||
_selections.get(type).getRight().put(src, dest);
|
||||
}
|
||||
};
|
||||
|
||||
final MigrationSourceAnalyzer msa = new MigrationSourceAnalyzer(_srcDir, cb);
|
||||
final int numFilesToAnalyze = msa.getNumFilesToAnalyze();
|
||||
|
||||
final Timer timer = new Timer(500, null);
|
||||
timer.addActionListener(new ActionListener() {
|
||||
@Override public void actionPerformed(ActionEvent arg0) {
|
||||
if (_cancel) {
|
||||
timer.stop();
|
||||
return;
|
||||
MigrationSourceAnalyzer.AnalysisCallback cb = new MigrationSourceAnalyzer.AnalysisCallback() {
|
||||
@Override
|
||||
public boolean checkCancel() { return _cancel; }
|
||||
|
||||
@Override
|
||||
public void addOp(OpType type, File src, File dest) {
|
||||
_selections.get(type).getRight().put(src, dest);
|
||||
}
|
||||
|
||||
// timers run in the gui event loop, so it's ok to interact with widgets
|
||||
_progressBar.setValue(msa.getNumFilesAnalyzed());
|
||||
|
||||
// only update if we don't already have an update pending. we may not be prompt in
|
||||
// updating sometimes, but that's ok
|
||||
if (!_uiUpdateAck) { return; }
|
||||
_uiUpdateAck = false;
|
||||
_stateChangedListener.stateChanged(null);
|
||||
};
|
||||
|
||||
final MigrationSourceAnalyzer msa = new MigrationSourceAnalyzer(_srcDir, cb);
|
||||
final int numFilesToAnalyze = msa.getNumFilesToAnalyze();
|
||||
|
||||
timer = new Timer(500, null);
|
||||
final Timer finalTimer = timer;
|
||||
timer.addActionListener(new ActionListener() {
|
||||
@Override public void actionPerformed(ActionEvent arg0) {
|
||||
if (_cancel) {
|
||||
finalTimer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// timers run in the gui event loop, so it's ok to interact with widgets
|
||||
_progressBar.setValue(msa.getNumFilesAnalyzed());
|
||||
|
||||
// only update if we don't already have an update pending. we may not be prompt in
|
||||
// updating sometimes, but that's ok
|
||||
if (!_uiUpdateAck) { return; }
|
||||
_uiUpdateAck = false;
|
||||
_stateChangedListener.stateChanged(null);
|
||||
}
|
||||
});
|
||||
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (_cancel) { return; }
|
||||
_progressBar.setMaximum(numFilesToAnalyze);
|
||||
_progressBar.setValue(0);
|
||||
_progressBar.setIndeterminate(false);
|
||||
|
||||
// start update timer
|
||||
finalTimer.start();
|
||||
}
|
||||
});
|
||||
|
||||
msa.doAnalysis();
|
||||
} catch (final Exception e) {
|
||||
_cancel = true;
|
||||
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override public void run() {
|
||||
_progressBar.setString("Error");
|
||||
BugReporter.reportException(e);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (null != timer)
|
||||
{
|
||||
timer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (_cancel) { return; }
|
||||
_progressBar.setMaximum(numFilesToAnalyze);
|
||||
_progressBar.setValue(0);
|
||||
_progressBar.setIndeterminate(false);
|
||||
|
||||
// start update timer
|
||||
timer.start();
|
||||
}
|
||||
});
|
||||
|
||||
msa.doAnalysis();
|
||||
timer.stop();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
|
||||
import com.google.common.base.Predicate;
|
||||
import com.google.common.collect.Iterables;
|
||||
@@ -36,6 +38,7 @@ import forge.item.CardDb;
|
||||
import forge.item.CardPrinted;
|
||||
import forge.item.IPaperCard;
|
||||
import forge.properties.NewConstants;
|
||||
import forge.util.FileUtil;
|
||||
|
||||
public class MigrationSourceAnalyzer {
|
||||
public static enum OpType {
|
||||
@@ -60,6 +63,8 @@ public class MigrationSourceAnalyzer {
|
||||
void addOp(OpType type, File src, File dest);
|
||||
}
|
||||
|
||||
private final Set<File> _unmappableFiles = new TreeSet<File>();
|
||||
|
||||
private final File _source;
|
||||
private final AnalysisCallback _cb;
|
||||
private final int _numFilesToAnalyze;
|
||||
@@ -83,6 +88,10 @@ public class MigrationSourceAnalyzer {
|
||||
_analyzeResDir(_source);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// pre-profile res dir
|
||||
//
|
||||
|
||||
private void _analyzeResDir(File root) {
|
||||
for (File file : root.listFiles()) {
|
||||
if (_cb.checkCancel()) { return; }
|
||||
@@ -100,6 +109,10 @@ public class MigrationSourceAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// default card pics
|
||||
//
|
||||
|
||||
private static String _oldCleanString(final String in) {
|
||||
final StringBuffer out = new StringBuffer();
|
||||
char c;
|
||||
@@ -166,8 +179,8 @@ public class MigrationSourceAnalyzer {
|
||||
if (_defaultPicOldNameToCurrentName.containsKey(fileName)) {
|
||||
fileName = _defaultPicOldNameToCurrentName.get(fileName);
|
||||
} else if (!_defaultPicNames.contains(fileName)) {
|
||||
// TODO: track the unmappables and prompt to delete them at the end
|
||||
System.out.println("skipping umappable default pic file: " + file);
|
||||
_unmappableFiles.add(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -187,6 +200,10 @@ public class MigrationSourceAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// set card pics
|
||||
//
|
||||
|
||||
private static void _addSetCards(Set<String> cardFileNames, Iterable<CardPrinted> library, Predicate<CardPrinted> filter) {
|
||||
for (CardPrinted c : Iterables.filter(library, filter)) {
|
||||
boolean hasBackFace = null != c.getRules().getPictureOtherSideUrl();
|
||||
@@ -235,6 +252,7 @@ public class MigrationSourceAnalyzer {
|
||||
}
|
||||
} else {
|
||||
System.out.println("skipping umappable set pic file: " + file);
|
||||
_unmappableFiles.add(file);
|
||||
}
|
||||
} else if (file.isDirectory()) {
|
||||
System.out.println("skipping umappable subdirectory: " + file);
|
||||
@@ -243,16 +261,87 @@ public class MigrationSourceAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// other image dirs
|
||||
//
|
||||
|
||||
Set<String> _iconFileNames;
|
||||
private void _analyzeIconsPicsDir(File root) {
|
||||
// TODO: implement
|
||||
_numFilesAnalyzed += _countFiles(root);
|
||||
if (null == _iconFileNames) {
|
||||
_iconFileNames = new HashSet<String>();
|
||||
for (Pair<String, String> nameurl : FileUtil.readNameUrlFile(NewConstants.IMAGE_LIST_QUEST_OPPONENT_ICONS_FILE)) {
|
||||
_iconFileNames.add(nameurl.getLeft());
|
||||
}
|
||||
for (Pair<String, String> nameurl : FileUtil.readNameUrlFile(NewConstants.IMAGE_LIST_QUEST_PET_SHOP_ICONS_FILE)) {
|
||||
_iconFileNames.add(nameurl.getLeft());
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("analyzing icon pics directory: " + root);
|
||||
for (File file : root.listFiles()) {
|
||||
if (_cb.checkCancel()) { return; }
|
||||
|
||||
if (file.isFile()) {
|
||||
++_numFilesAnalyzed;
|
||||
if (!_iconFileNames.contains(file.getName())) {
|
||||
System.out.println("skipping umappable icon pic file: " + file);
|
||||
_unmappableFiles.add(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
File targetFile = new File(NewConstants.CACHE_ICON_PICS_DIR, file.getName());
|
||||
if (!file.equals(targetFile)) {
|
||||
_cb.addOp(OpType.QUEST_DATA, file, targetFile);
|
||||
}
|
||||
} else if (file.isDirectory()) {
|
||||
System.out.println("skipping umappable subdirectory: " + file);
|
||||
_numFilesAnalyzed += _countFiles(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> _tokenFileNames;
|
||||
Set<String> _questTokenFileNames;
|
||||
private void _analyzeTokenPicsDir(File root) {
|
||||
// TODO: implement
|
||||
_numFilesAnalyzed += _countFiles(root);
|
||||
if (null == _tokenFileNames) {
|
||||
_tokenFileNames = new HashSet<String>();
|
||||
_questTokenFileNames = new HashSet<String>();
|
||||
for (Pair<String, String> nameurl : FileUtil.readNameUrlFile(NewConstants.IMAGE_LIST_TOKENS_FILE)) {
|
||||
_tokenFileNames.add(nameurl.getLeft());
|
||||
}
|
||||
for (Pair<String, String> nameurl : FileUtil.readNameUrlFile(NewConstants.IMAGE_LIST_QUEST_TOKENS_FILE)) {
|
||||
_questTokenFileNames.add(nameurl.getLeft());
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("analyzing token pics directory: " + root);
|
||||
for (File file : root.listFiles()) {
|
||||
if (_cb.checkCancel()) { return; }
|
||||
|
||||
if (file.isFile()) {
|
||||
++_numFilesAnalyzed;
|
||||
boolean isQuestToken = _questTokenFileNames.contains(file.getName());
|
||||
if (!isQuestToken && !_tokenFileNames.contains(file.getName())) {
|
||||
System.out.println("skipping umappable token pic file: " + file);
|
||||
_unmappableFiles.add(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
File targetFile = new File(NewConstants.CACHE_TOKEN_PICS_DIR, file.getName());
|
||||
if (!file.equals(targetFile)) {
|
||||
_cb.addOp(isQuestToken ? OpType.QUEST_PIC : OpType.TOKEN_PIC, file, targetFile);
|
||||
}
|
||||
} else if (file.isDirectory()) {
|
||||
System.out.println("skipping umappable subdirectory: " + file);
|
||||
_numFilesAnalyzed += _countFiles(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// utility functions
|
||||
//
|
||||
|
||||
private int _countFiles(File root) {
|
||||
int count = 0;
|
||||
for (File file : root.listFiles()) {
|
||||
|
||||
@@ -51,7 +51,7 @@ public class GuiDownloadPicturesLQ extends GuiDownloader {
|
||||
}
|
||||
|
||||
// Add missing tokens to the list of things to download.
|
||||
for (final DownloadObject element : GuiDownloader.readFileWithNames(NewConstants.IMAGE_LIST_TOKENS_FILE, NewConstants.CACHE_TOKEN_PICS_DIR)) {
|
||||
for (final DownloadObject element : GuiDownloader.readFile(NewConstants.IMAGE_LIST_TOKENS_FILE, NewConstants.CACHE_TOKEN_PICS_DIR)) {
|
||||
if (!element.getDestination().exists()) {
|
||||
downloads.add(element);
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ public class GuiDownloadQuestImages extends GuiDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
for (final DownloadObject petIcon : GuiDownloader.readFileWithNames(NewConstants.IMAGE_LIST_QUEST_PET_SHOP_ICONS_FILE, NewConstants.CACHE_ICON_PICS_DIR)) {
|
||||
for (final DownloadObject petIcon : GuiDownloader.readFile(NewConstants.IMAGE_LIST_QUEST_PET_SHOP_ICONS_FILE, NewConstants.CACHE_ICON_PICS_DIR)) {
|
||||
if (!petIcon.getDestination().exists()) {
|
||||
urls.add(petIcon);
|
||||
}
|
||||
}
|
||||
|
||||
for (final DownloadObject questPet : GuiDownloader.readFileWithNames(NewConstants.IMAGE_LIST_QUEST_TOKENS_FILE, NewConstants.CACHE_TOKEN_PICS_DIR)) {
|
||||
for (final DownloadObject questPet : GuiDownloader.readFile(NewConstants.IMAGE_LIST_QUEST_TOKENS_FILE, NewConstants.CACHE_TOKEN_PICS_DIR)) {
|
||||
if (!questPet.getDestination().exists()) {
|
||||
urls.add(questPet);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public class GuiDownloadSetPicturesLQ extends GuiDownloader {
|
||||
}
|
||||
|
||||
// Add missing tokens to the list of things to download.
|
||||
for (final DownloadObject element : GuiDownloader.readFileWithNames(NewConstants.IMAGE_LIST_TOKENS_FILE, NewConstants.CACHE_TOKEN_PICS_DIR)) {
|
||||
for (final DownloadObject element : GuiDownloader.readFile(NewConstants.IMAGE_LIST_TOKENS_FILE, NewConstants.CACHE_TOKEN_PICS_DIR)) {
|
||||
if (!element.getDestination().exists()) {
|
||||
downloads.add(element);
|
||||
}
|
||||
|
||||
@@ -32,9 +32,7 @@ import java.net.MalformedURLException;
|
||||
import java.net.Proxy;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.swing.AbstractButton;
|
||||
import javax.swing.DefaultBoundedRangeModel;
|
||||
@@ -50,7 +48,7 @@ import javax.swing.event.ChangeListener;
|
||||
|
||||
import net.miginfocom.swing.MigLayout;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
|
||||
import com.esotericsoftware.minlog.Log;
|
||||
|
||||
@@ -374,50 +372,14 @@ public abstract class GuiDownloader extends DefaultBoundedRangeModel implements
|
||||
|
||||
protected abstract ArrayList<DownloadObject> getNeededImages();
|
||||
|
||||
protected static List<DownloadObject> readFile(final String urlsFile, String dir) {
|
||||
List<String> fileLines = FileUtil.readFile(urlsFile);
|
||||
protected static ArrayList<DownloadObject> readFile(final String nameUrlFile, final String dir) {
|
||||
final ArrayList<DownloadObject> list = new ArrayList<DownloadObject>();
|
||||
final Pattern splitter = Pattern.compile(Pattern.quote("/"));
|
||||
final Pattern replacer = Pattern.compile(Pattern.quote("%20"));
|
||||
|
||||
for (String line : fileLines) {
|
||||
|
||||
if (line.equals("") || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] parts = splitter.split(line);
|
||||
|
||||
// Maybe there's a better way to do this, but I just want the
|
||||
// filename from a URL
|
||||
String last = parts[parts.length - 1];
|
||||
list.add(new DownloadObject(line, new File(dir, replacer.matcher(last).replaceAll(" "))));
|
||||
for (Pair<String, String> nameUrlPair : FileUtil.readNameUrlFile(nameUrlFile)) {
|
||||
list.add(new DownloadObject(nameUrlPair.getRight(), new File(dir, nameUrlPair.getLeft())));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
protected static ArrayList<DownloadObject> readFileWithNames(final String urlNamesFile, final String dir) {
|
||||
List<String> fileLines = FileUtil.readFile(urlNamesFile);
|
||||
final ArrayList<DownloadObject> list = new ArrayList<DownloadObject>();
|
||||
final Pattern splitter = Pattern.compile(Pattern.quote(" "));
|
||||
final Pattern replacer = Pattern.compile(Pattern.quote("%20"));
|
||||
|
||||
for (String line : fileLines) {
|
||||
|
||||
if (StringUtils.isBlank(line) || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = splitter.split(line, 2);
|
||||
String url = parts.length > 1 ? parts[1] : null;
|
||||
list.add(new DownloadObject(url, new File(dir, replacer.matcher(parts[0]).replaceAll(" "))));
|
||||
}
|
||||
|
||||
return list;
|
||||
} // readFile()
|
||||
|
||||
/**
|
||||
* The Class ProxyHandler.
|
||||
*/
|
||||
protected class ProxyHandler implements ChangeListener {
|
||||
private final int type;
|
||||
|
||||
@@ -450,7 +412,7 @@ public abstract class GuiDownloader extends DefaultBoundedRangeModel implements
|
||||
DownloadObject(final String srcUrl, final File destFile) {
|
||||
source = srcUrl;
|
||||
destination = destFile;
|
||||
// System.out.println("Created download object: "+name+" "+url+" "+dir);
|
||||
System.out.println(String.format("downloading %s to %s", srcUrl, destFile));
|
||||
}
|
||||
|
||||
/** @return {@link java.lang.String} */
|
||||
|
||||
@@ -17,22 +17,19 @@
|
||||
*/
|
||||
package forge.util;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.Reader;
|
||||
import java.net.Proxy;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.swing.JOptionPane;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
|
||||
import forge.error.BugReporter;
|
||||
|
||||
@@ -177,25 +174,30 @@ public final class FileUtil {
|
||||
return list;
|
||||
}
|
||||
|
||||
public static void downloadUrlIntoFile(final String url, final File target) {
|
||||
try {
|
||||
final byte[] buf = new byte[1024];
|
||||
int len;
|
||||
// returns a list of <name, url> pairs. if the name is not in the file, it is synthesized from the url
|
||||
public static List<Pair<String, String>> readNameUrlFile(String nameUrlFile) {
|
||||
Pattern lineSplitter = Pattern.compile(Pattern.quote(" "));
|
||||
Pattern replacer = Pattern.compile(Pattern.quote("%20"));
|
||||
|
||||
final Proxy p = Proxy.NO_PROXY;
|
||||
final BufferedInputStream in = new BufferedInputStream(new URL(url).openConnection(p).getInputStream());
|
||||
final BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(target));
|
||||
List<Pair<String, String>> list = new ArrayList<Pair<String, String>>();
|
||||
|
||||
// while - read and write file
|
||||
while ((len = in.read(buf)) != -1) {
|
||||
out.write(buf, 0, len);
|
||||
} // while - read and write file
|
||||
in.close();
|
||||
out.flush();
|
||||
out.close();
|
||||
} catch (final IOException ioex) {
|
||||
JOptionPane.showMessageDialog(null, String.format("Error connecting to: %s", url),
|
||||
"Download error", JOptionPane.ERROR_MESSAGE);
|
||||
for (String line : readFile(nameUrlFile)) {
|
||||
if (StringUtils.isBlank(line) || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] parts = lineSplitter.split(line, 2);
|
||||
if (2 == parts.length) {
|
||||
list.add(Pair.of(replacer.matcher(parts[0]).replaceAll(" "), parts[1]));
|
||||
} else {
|
||||
// figure out the filename from the URL
|
||||
Pattern pathSplitter = Pattern.compile(Pattern.quote("/"));
|
||||
String[] pathParts = pathSplitter.split(parts[0]);
|
||||
String last = pathParts[pathParts.length - 1];
|
||||
list.add(Pair.of(replacer.matcher(last).replaceAll(" "), parts[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user