Looping and appending text to StringBuilder issue - c#

I have a small issue when trying to append text from a loop into a stringbuilder, after trying a few things out, i think i'm on the right track with this.
Code:
private void bgWorker_DoWork(object sender, DoWorkEventArgs e)
{
// STRING VALUE IT SO WE CAN REUSE //
string action = e.Argument as string;
// CONNECTION //
if (action == "wraith_create_project")
{
// STRING BUILDER //
StringBuilder sb = new StringBuilder();
// VARS //
var articleSource = "";
// INVOKE - AVOID CROSS THREAD ERRORS //
Invoke(new MethodInvoker(() => { articleSource = comboBoxArticleSources.Text; }));
// TRY/CATCH //
try
{
// INVOKE - AVOID CROSS THREAD ERRORS //
Invoke(new MethodInvoker(() => { listBoxMain.Items.Add("[" + DateTime.Now + "] Creating project ... " + txtBoxProjectName.Text); }));
// ARTICLE SOURCE //
if (articleSource == "Internal Article Builder")
{
// VARS/CONSTANTS //
var separator = Environment.NewLine;
const string gsaSeparator = "\x01";
// WHICH SPINNER TO USE //
if (chkBoxInternalSpinner.Checked) {
// LOOP //
var title = "";
var body = "";
var hash = "";
//var gsaArticleInfo = "";
for (int x = 0; x <= 5; x++ ) {
// EVERY LOOP REQUESTS AN ARTICLE //
var requestArticles = Helpers.getArticleTitleAndBodyInternalSpinner("https://www.wraithseo.com/api.php?articleBuilder=1&q=" + txtBoxScrapeKeyword.Text.Replace(" ", "_"));
title = Helpers.internalSpinner(requestArticles.Item1); // SEND TO INTERNAL SPINNER FOR SPINNING ...
body = Helpers.internalSpinner(requestArticles.Item2); // SEND TO INTERNAL SPINNER FOR SPINNING ...
hash = To32BitFnv1aHash(body).ToString("X8");
Invoke(new MethodInvoker(() =>
{
listBoxMain.Items.Add("[" + DateTime.Now + "] Returned article ... " + requestArticles.Item1);
listBoxMain.Items.Add("[" + DateTime.Now + "] Spun the article ... " + title);
}));
// ENCODE WITH THE GSA SEPERATOR BETWEEN EACH FIELD //
var gsaArticleInfo = title + gsaSeparator + "%first_paragraph-article%" + gsaSeparator + body + gsaSeparator + hash;
// ADD TO THE RICHTEXTBOX ALL FIELDS FROM ABOVE //
var richTextBoxText = string.Join(separator, gsaArticleInfo);
// APPEND FIELDS TO THE STRINGBUILDER //
sb.Append(richTextBoxText);
}
// INVOKE - AVOID CROSS THREAD ERRORS //
Invoke(new MethodInvoker(() => { richTxtBoxArticle.Text = sb.ToString(); }));
}
} else if (articleSource == "") {
// RESERVED FOR ADDITIONAL SOURCES //
}
} catch (Exception ex) {
Helpers.returnMessage(ex.ToString());
}
}
}
What i'm doing here is sending a request to my server which is returning an article sized bunch of text, i'm then spinning that text and trying to display it in a richTextBox, i noticed that instead of adding each article to the richTextBox it was being overwritten by the same article, i thought by using the stringbuilder to append the article to it on each loop i could then display it outside the loop but it always seems to overwrite, instead of adding 5 articles (which is the max i have set for tetsing)
Any help would be appreciated.
Updated Code:
private void bgWorker_DoWork(object sender, DoWorkEventArgs e)
{
string action = e.Argument as string;
if (action == "wraith_create_project")
{
StringBuilder sb = new StringBuilder();
var separator = Environment.NewLine;
const string gsaSeparator = "\x01";
var articleSource = "";
var title = "";
var body = "";
var hash = "";
var gsaArticleInfo = "";
var richTextBoxText = "";
// TEST //
List<string> gsaStuff = new List<string>();
// TEST //
Invoke(new MethodInvoker(() => { articleSource = comboBoxArticleSources.Text; }));
try
{
Invoke(new MethodInvoker(() => { listBoxMain.Items.Add("[" + DateTime.Now + "] Creating project ... " + txtBoxProjectName.Text); }));
if (articleSource == "Internal Article Builder")
{
if (chkBoxInternalSpinner.Checked) {
for (int x = 0; x <= 5; x++ ) {
// EVERY LOOP REQUESTS AN ARTICLE WHICH IS RETURNED AT RAND() //
var requestArticles = Helpers.getArticleTitleAndBodyInternalSpinner("https://www.wraithseo.com/api.php?articleBuilder=1&q=" + txtBoxScrapeKeyword.Text.Replace(" ", "_"));
title = Helpers.internalSpinner(requestArticles.Item1); // SEND TO INTERNAL SPINNER FOR SPINNING ...
body = Helpers.internalSpinner(requestArticles.Item2); // SEND TO INTERNAL SPINNER FOR SPINNING ...
hash = To32BitFnv1aHash(body).ToString("X8");
Invoke(new MethodInvoker(() =>
{
listBoxMain.Items.Add("[" + DateTime.Now + "] Returned article ... " + requestArticles.Item1);
listBoxMain.Items.Add("[" + DateTime.Now + "] Spun the article ... " + title);
}));
// ENCODE WITH THE GSA SEPERATOR BETWEEN EACH FIELD //
gsaArticleInfo = title + gsaSeparator + "%first_paragraph-article%" + gsaSeparator + body + gsaSeparator + hash;
// ADD TO THE RICHTEXTBOX ALL FIELDS FROM ABOVE //
richTextBoxText = string.Join(separator, gsaArticleInfo);
// APPEND FIELDS TO THE STRINGBUILDER //
sb.Append(string.Join(separator, gsaArticleInfo));
gsaStuff.Add(richTextBoxText);
// INVOKE - AVOID CROSS THREAD ERRORS //
Invoke(new MethodInvoker(() => { richTxtBoxArticle.AppendText(richTextBoxText); }));
Invoke(new MethodInvoker(() => { richTxtBoxArticle.Lines = gsaStuff.ToArray(); }));
} // End for loop.
Invoke(new MethodInvoker(() => { richTxtBoxArticle.Lines = gsaStuff.ToArray(); }));
//Helpers.returnMessage("SB Contents: > " + sb.ToString());
} // End checkbox.
} else if (articleSource == "") {
// RESERVED FOR ADDITIONAL SOURCES //
}
} catch (Exception ex) {
Helpers.returnMessage(ex.ToString());
}
}
}

This is happening because you're calling onto the UI thread from another thread REALLY QUICKLY. The textbox simply can't keep up.
The solution here is to use the BackgroundWorker.ReportProgress method, and pass the strings back out of your worker to your UI thread. You'd handle the ProgressChanged event of the worker from your Form, and update your controls within there instead.
Hope that helps you out.

Related

Cleaning up nested if statements to be more readable?

I'm writing a project, and the part I'm doing now is getting arrow shaped real fast. How can I remove the nested if statements, but still have the same behaviour?
The code below might not look so bad now, but I'm planning on refactoring to include more methods.
public async Task FirstDiffTestAsync()
{
string folderDir = "../../../";
string correctReportDir = folderDir + "Reports To Compare/Testing - Copy.pdf";
string OptyNumber = "122906";
//Making a POST call to generate report
string result = ReportGeneration(OptyNumber).Result;
Response reportResponse = JsonConvert.DeserializeObject<Response>(result);
string newURL = reportResponse.documentUrl;
//Logging the Response to a text file for tracking purposes
await File.WriteAllTextAsync(Context.TestRunDirectory + "/REST_Response.txt", result);
using (StreamWriter w = File.AppendText(Context.TestDir + "/../log.txt"))
{
//Checking if the Integration failed
if (reportResponse.Error == null)
{
//now we have the url, reading in the pdf reports
List<string> Files = new List<string> { correctReportDir, newURL };
List<string> parsedText = PdfToParsedText(Files);
DiffPaneModel diff = InlineDiffBuilder.Diff(parsedText[0], parsedText[1]);
// DiffReport is a customised object
DiffReport diffReport = new DiffReport(correctReportDir, newURL);
diffReport.RunDiffReport(diff);
//In-test Logging
string indent = "\n - ";
string logMsg = $"{indent}Opty Number: {OptyNumber}{indent}Activity Number: {reportResponse.ActivityNumber}{indent}File Name: {reportResponse.FileName}";
if (diffReport.totalDiff != 0)
{
await File.WriteAllTextAsync(Context.TestRunDirectory + "/DiffReport.html", diffReport.htmlDiffHeader + diffReport.htmlDiffBody);
logMsg += $"{indent}Different lines: {diffReport.insertCounter} Inserted, {diffReport.deleteCounter} Deleted";
}
LogTesting(logMsg, w);
//Writing HTML report conditionally
if (diffReport.totalDiff != 0)
{
await File.WriteAllTextAsync(Context.TestRunDirectory + "/DiffReport.html", diffReport.htmlDiffHeader + diffReport.htmlDiffBody);
}
Assert.IsTrue(diffReport.insertCounter + diffReport.deleteCounter == 0);
}
else
{
LogTesting($" Integration Failed: {reportResponse.Error}", w);
Assert.IsNull(reportResponse.Error);
}
}
}
As mentioned in the comment, the indentation level is fine for now, but its always better to minimize when possible, especially when you are repeating same blocks of code.
The best way to do this is to write a separate function that contains that block of code and then call that function instead of the nested if statements.
In your case it would be something like this:
private async void checkTotalDiff(diffReport) {
...
}
You could pass anything you might need in the parameters. This way in your main code, you could replace the if statements with checkTotalDiff(diffReport) and save the return (if any) to a variable.
Also note I used void for return but you could change the type depending on what the function returns.
I wouldn't consider this as having an excessive amount of nested if-statements. It is fine as is. Otherwise you could do the following (also suggested by #Caius Jard):
public async Task FirstDiffTestAsync()
{
string folderDir = "../../../";
string correctReportDir = folderDir + "Reports To Compare/Testing - Copy.pdf";
string OptyNumber = "122906";
//Making a POST call to generate report
string result = ReportGeneration(OptyNumber).Result;
Response reportResponse = JsonConvert.DeserializeObject<Response>(result);
//Checking if the Integration failed
if (reportResponse.Error != null)
{
LogTesting($" Integration Failed: {reportResponse.Error}", w);
Assert.IsNull(reportResponse.Error);
return;
}
string newURL = reportResponse.documentUrl;
//Logging the Response to a text file for tracking purposes
await File.WriteAllTextAsync(Context.TestRunDirectory + "/REST_Response.txt", result);
using (StreamWriter w = File.AppendText(Context.TestDir + "/../log.txt"))
{
//now we have the url, reading in the pdf reports
List<string> Files = new List<string> { correctReportDir, newURL };
List<string> parsedText = PdfToParsedText(Files);
DiffPaneModel diff = InlineDiffBuilder.Diff(parsedText[0], parsedText[1]);
// DiffReport is a customised object
DiffReport diffReport = new DiffReport(correctReportDir, newURL);
diffReport.RunDiffReport(diff);
//In-test Logging
string indent = "\n - ";
string logMsg = $"{indent}Opty Number: {OptyNumber}{indent}Activity Number: {reportResponse.ActivityNumber}{indent}File Name: {reportResponse.FileName}";
if (diffReport.totalDiff != 0)
{
await File.WriteAllTextAsync(Context.TestRunDirectory + "/DiffReport.html", diffReport.htmlDiffHeader + diffReport.htmlDiffBody);
logMsg += $"{indent}Different lines: {diffReport.insertCounter} Inserted, {diffReport.deleteCounter} Deleted";
}
LogTesting(logMsg, w);
//Writing HTML report conditionally
if (diffReport.totalDiff != 0)
{
await File.WriteAllTextAsync(Context.TestRunDirectory + "/DiffReport.html", diffReport.htmlDiffHeader + diffReport.htmlDiffBody);
}
Assert.IsTrue(diffReport.insertCounter + diffReport.deleteCounter == 0);
}
}

While statement for downloading URL strings

I would like to ask, How I can use the While statement for downloading URL?
Here's what I want to happen.
I want to check if the url from CheckBoxListItems is already exist from my ListView
There's a case that I'm adding another urls to my listbox and I don't want to download again.
if url is already exists in my listview it will skip and proceed to the next url(which is not yet downloaded).
Here's my current codes:
int count = 0;
int total = LB.CheckedItems.Count;
string counter = string.Empty;
using (cts = new CancellationTokenSource())
{
try
{
if (cts.IsCancellationRequested) { throw new TaskCanceledException(); }
txtOutput.Text = string.Empty;
Cursor = Cursors.WaitCursor;
Parsing = true;
Text = "Getting links information. Please wait...";
foreach (string url in LB.CheckedItems)
{
var info = await Task.Run(() => Parser.GetJsonData(url, cts.Token));
count++;
counter = "( " + count + " of " + total + " )";
lblTotalLinks.Text = counter;
Text = "Parsing in progress. Please wait... " + counter;
AddToListView(info); //ADD DOWNLOADED STRINGS TO LISTVIEW
}
Text = "Parsing done. " + counter;
}
catch (OperationCanceledException ex)
{ Text = ex.Message; }
catch (Exception ex)
{ MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); }
}
Parsing = false;
Cursor = Cursors.Default;
cts = null;
//ADD TO LISTVIEW()
private void AddToListView(MediaInfo info)
{
int count = LV.Items.Count + 1;
var results = new List<ListViewItem> { new ListViewItem(new[]
{
count.ToString(),
info.Series,
"Episode " + info.Episode,
info.Title,
info.Runtime,
info.Resolution,
info.Category,
info.URL, //here's what I want to check if already exists
info.M3u8_url,
info.FileSize,
info.Fragments
})};
ListViewItem[] array = results.ToArray();
LV.BeginUpdate();
LV.ListViewItemSorter = null;
LV.Items.AddRange(array);
LV.Focus();
LV.EndUpdate();
Countlists();
LV.Items[LV.Items.Count - 1].EnsureVisible();
}
this is the example of what I want:
string urlExists = string.Empty;
foreach (ListViewItem item in LV.Items)
{
urlExists = item.SubItems[7].Text;
foreach (string url in LB.CheckedItems)
{
while (url != urlExists)
{
}
}

Update programmatically image after Event with KNX.Net

I'm creating a webapp to control some lights in my house. I can't understand why after firing the Event void, the image is not updating; while if i fire it from a button it is actually changing.
I have tried this so far, using KNX.Net libraries
...
public void Event(string address, string state)
{
if (address.Equals(CH03LightOnOffAddressStatus) || address.Equals(CH04LightOnOffAddressStatus))
{
System.Diagnostics.Debug.WriteLine("New Event: device " + address + " has status (" + state + ") --> " + _connection.FromDataPoint("9.001", state));
}
else if (
address.Equals(CH01LightOnOffAddressStatus) ||
address.Equals(CH02LightOnOffAddressStatus)
)
{
var data = string.Empty;
if (state.Length == 1)
{
data = ((byte)state[0]).ToString();
}
else
{
var bytes = new byte[state.Length];
for (var i = 0; i < state.Length; i++)
{
bytes[i] = Convert.ToByte(state[i]);
}
data = state.Aggregate(data, (current, t) => current + t.ToString());
}
System.Diagnostics.Debug.WriteLine("New Event: device " + address + " has status --> " + data);
condition = data;
nowAddress = address;
}
if (condition == "1")
{
Image1.ImageUrl = #"\Res\Call.png";
}
else
{
Image1.ImageUrl = #"\Res\Bomb.png";
}
}
...
While if i fire it like this, the image is changing indeed
...
private void CH01LightOn()
{
_connection.Action(CH01LightOnOffAddress, true);
Thread.Sleep(500);
if (condition == "1")
{
Image1.ImageUrl = #"\Res\Call.png";
}
else
{
Image1.ImageUrl = #"\Res\Bomb.png";
}
}
...
I just need the images to be changing after the event is fired.
Thank you in advance.
This is probably a threading issue. If the Event method is not called on the UI thread, you cannot access your WinForms controls directly, but have to use Control.Invoke.
To try this, replace Image1.ImageUrl = #"\Res\Call.png" by:
if (Image1.InvokeRequired)
{
Image1.Invoke((Action)(() => Image1.ImageUrl = #"\Res\Call.png"));
}
else
{
Image1.ImageUrl = #"\Res\Call.png"
}

C# Pause during foreach loop until Process.Exited

So I have a foreach loop that goes through a DataGridView. For each row in the grid it executes a pspasswrd.exe and changes the password of a local account. How can I pause the foreach loop until the process completes before moving onto the next computer in the list?
I am not sure, I tried WaitForExit but this is not reliable since it is synchronous so it cause my program to not respond if it takes a long time. I need to utilize Process.Exited but unsure how to do this correctly.
Here is my foreach loop. Which also checks if the password was changed successfully. And inputs data into another Datagridview if it was success.
foreach (DataGridViewRow row in dgvData.Rows)
{
if (!row.IsNewRow && row.Cells["cIPAddress"].Value.ToString().Contains("Invalid") == false)
{
if (pspasswrd(row.Cells["cIPAddress"].Value.ToString(), tbTargetUser.Text, NewPassword).Contains("Password successfully changed"))
{
dgvResults.Rows.Add(row.Cells["cIPAddress"].Value.ToString(), tbTargetUser.Text, NewPassword);
AppendNumber = AppendNumber + IncreaseNumber;
NewPassword = BasePassword + AppendNumber;
}
else
row.DefaultCellStyle.BackColor = Color.Red;
}
Thread.Sleep(1000);
}
But what I am unsure is that if I change my code below to use process.exited. How will it associate the password change to the computer it was successfully on. As it will be asynchronous.
public string pspasswrd(string ip, string user, string password)
{
String CD = #Directory.GetCurrentDirectory() + "\\pspasswrd.exe";
ProcessStartInfo p = new ProcessStartInfo();
string result = null;
p.FileName = CD;
p.Arguments = #"\\" + ip + " " + user + " " + password + " -accepteula";
p.CreateNoWindow = true;
p.RedirectStandardOutput = true;
p.RedirectStandardError = true;
p.UseShellExecute = false;
Process x = Process.Start(p);
StreamReader stream = x.StandardOutput;
result = stream.ReadToEnd();
x.WaitForExit(2000);
x.Close();
return result;
}
You should first collect all the needed information from DataGridView in a collection then run your code for executing pspasswrd.exe in a background thread looping on the collection instead of DataGridView and use WaitForExit, this will not block your UI thread.
EDIT
Here is the code with WaitForExit and a new thread. I believe that you will not gain much using the Exit event instead of WaitForExit other then much more complexity and deadlock scenarios:
private void OnSomeUIEvent()
{
//In UI thread
var ipRows = new Dictionary<string, DataGridViewRow>();
var targetUser = ""; //tbTargetUser.Text
var pwd = ""; //NewPassword
var basePassword = ""; //Some value
foreach (DataGridViewRow row in dgvData.Rows)
{
var ipAddress = row.Cells["cIPAddress"].Value.ToString();
if (!row.IsNewRow && ipAddress.Contains("Invalid") == false)
{
ipRows.Add(ipAddress, row);
}
}
Task.Factory.StartNew(() => {
ChangePassword(ipRows, targetUser, pwd, basePassword);
}).ContinueWith(t => {
//Do Something when task completed
});
}
private void ChangePassword(Dictionary<string, DataGridViewRow> ipRows, string targetUser, string newPwd, string basePwd)
{ //in background thread
foreach (var ipRow in ipRows)
{
var pwd = newPwd;
var basePassword = basePwd;
var appendNumber = 0;
var increaseNumber = 1; //some number
if (pspasswrd(ipRow.Key, targetUser, pwd).Contains("Password successfully changed"))
{
dgvResults.Invoke((MethodInvoker)(() =>
{
dgvResults.Rows.Add(ipRow.Key, targetUser, pwd);
}));
appendNumber = appendNumber + increaseNumber;
pwd = basePassword + increaseNumber;
}
else
{
dgvData.Invoke((MethodInvoker)(() =>
{
ipRow.Value.DefaultCellStyle.BackColor = Color.Red;
}));
}
}
}

Cancel Async Task from a button

What I need to do is be able to cancel a task that is running async.
I have been searching and cannot seem to wrap my head around it. I just cant seem to discern how it would be implemented into my current setup.
Here is my code that fires my task off. Any help on where or how to implement a cancellation token would be greatly appreciated.
private async void startThread()
{
//do ui stuff before starting
ProgressLabel.Text = String.Format("0 / {0} Runs Completed", index.Count());
ProgressBar.Maximum = index.Count();
await ExecuteProcesses();
//sort list of output lines
outputList = outputList.OrderBy(o => o.RunNumber).ToList();
foreach (Output o in outputList)
{
string outStr = o.RunNumber + "," + o.Index;
foreach (double oV in o.Values)
{
outStr += String.Format(",{0}", oV);
}
outputStrings.Add(outStr);
}
string[] csvOut = outputStrings.ToArray();
File.WriteAllLines(settings.OutputFile, csvOut);
//do ui stuff after completing.
ProgressLabel.Text = index.Count() + " runs completed. Output written to file test.csv";
}
private async Task ExecuteProcesses()
{
await Task.Factory.StartNew(() =>
{
int myCount = 0;
int maxRuns = index.Count();
List<string> myStrings = index;
Parallel.ForEach(myStrings,
new ParallelOptions()
{
MaxDegreeOfParallelism = settings.ConcurrentRuns
}, (s) =>
{
//This line gives us our run count.
int myIndex = myStrings.IndexOf(s) + 1;
string newInputFile = Path.Combine(settings.ProjectPath + "files/", Path.GetFileNameWithoutExtension(settings.InputFile) + "." + s + ".inp");
string newRptFile = Path.Combine(settings.ProjectPath + "files/", Path.GetFileNameWithoutExtension(settings.InputFile) + "." + s + ".rpt");
try
{
//load in contents of input file
string[] allLines = File.ReadAllLines(Path.Combine(settings.ProjectPath, settings.InputFile));
string[] indexSplit = s.Split('.');
//change parameters here
int count = 0;
foreach (OptiFile oF in Files)
{
int i = Int32.Parse(indexSplit[count]);
foreach (OptiParam oP in oF.Parameters)
{
string line = allLines[oP.LineNum - 1];
if (oP.DecimalPts == 0)
{
string sExpression = oP.Value;
sExpression = sExpression.Replace("%i", i.ToString());
EqCompiler oCompiler = new EqCompiler(sExpression, true);
oCompiler.Compile();
int iValue = (int)oCompiler.Calculate();
allLines[oP.LineNum - 1] = line.Substring(0, oP.ColumnNum - 1) + iValue.ToString() + line.Substring(oP.ColumnNum + oP.Length);
}
else
{
string sExpression = oP.Value;
sExpression = sExpression.Replace("%i", i.ToString());
EqCompiler oCompiler = new EqCompiler(sExpression, true);
oCompiler.Compile();
double dValue = oCompiler.Calculate();
dValue = Math.Round(dValue, oP.DecimalPts);
allLines[oP.LineNum - 1] = line.Substring(0, oP.ColumnNum - 1) + dValue.ToString() + line.Substring(oP.ColumnNum + oP.Length);
}
}
count++;
}
//write new input file here
File.WriteAllLines(newInputFile, allLines);
}
catch (IOException ex)
{
MessageBox.Show(ex.ToString());
}
var process = new Process();
process.StartInfo = new ProcessStartInfo("swmm5.exe", newInputFile + " " + newRptFile);
process.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
process.Start();
process.WaitForExit();
Output output = new Output();
output.RunNumber = myIndex;
output.Index = s;
output.Values = new List<double>();
foreach(OutputValue oV in OutputValues) {
output.Values.Add(oV.getValue(newRptFile));
}
outputList.Add(output);
//get rid of files after run
File.Delete(newInputFile);
File.Delete(newRptFile);
myCount++;
ProgressBar.BeginInvoke(
new Action(() =>
{
ProgressBar.Value = myCount;
}
));
ProgressLabel.BeginInvoke(
new Action(() =>
{
ProgressLabel.Text = String.Format("{0} / {1} Runs Completed", myCount, maxRuns);
}
));
});
});
}
The best way to support cancellation is to pass a CancellationToken to the async method. The button press can then be tied to cancelling the token
class TheClass
{
CancellationTokenSource m_source;
void StartThread() {
m_source = new CancellationTokenSource;
StartThread(m_source.Token);
}
private async void StartThread(CancellationToken token) {
...
}
private void OnCancelClicked(object sender, EventArgs e) {
m_source.Cancel();
}
}
This isn't quite enough though. Both the startThread and StartProcess methods will need to be updated to cooperatively cancel the task once the CancellationToken registers as cancelled

Categories

Resources