Autoincrement end of filename if previous set of files exist - c#

I need to create x number of files (a set) but I must first check to see if the files exist with a similar name.
For example, tiftest1.tif, tiftest2.tif, ... exist and I must write tiftest again to the same directory. I'd like to append _x to the end of the filename, where x is a number that is auto-incremented, each time that I want to create the set. So I can have tiftest1_1.tif,tiftest2_1.tif, tiftest1_2.tif, tiftest2_2.tif, tiftest1_3.tif, tiftest2_3.tif and so forth.
Here is what I have so far:
...
DirectoryInfo root = new DirectoryInfo(fileWatch.Path);
FileInfo[] exist = root.GetFiles(fout + "*.tif");
if (exist.Length > 0)
{
int cnt = 0;
do
{
cnt++;
DirectoryInfo root1 = new DirectoryInfo(fileWatch.Path);
FileInfo[] exist1 = root.GetFiles(fout + "*" + "_" + cnt + ".tif");
arg_proc = "-o " + "\"" + fileWatch.Path
+ "\\" + fout + "%03d_" + cnt + ".tif\" -r " + "\"" + openDialog.FileName + "\"";
} while (exist1.Length > 0); //exist1 is out of scope so this doesn't work
}
else
{
arg_proc = "-o " + "\"" + fileWatch.Path
+ "\\" + fout + "%03d.tif\" -r " + "\"" + openDialog.FileName + "\"";
}
...
exist1.length is out of scope so the loop will continually run. I'm not certain how to correct this. My method was to originally scan the directory for a match and see if the length of the array is greater than 0. If it is > 0 then the _x will autoincrement until a match isn't found. arg_proc is a string used in a function (not included) which will create the files.

Can't you just reuse your exist variable?
FileInfo[] exist = root.GetFiles(fout + "*.tif");
if (exist.Length > 0)
{
int cnt = 0;
do
{
cnt++;
DirectoryInfo root1 = new DirectoryInfo(fileWatch.Path);
exist = root.GetFiles(fout + "*" + "_" + cnt + ".tif");
arg_proc = "-o " + "\"" + fileWatch.Path + "\\"
+ fout + "%03d_" + cnt + ".tif\" -r " + "\"" + openDialog.FileName + "\"";
} while (exist.Length > 0);
}
...
This way, exist won't be out of scope. It didn't look like you needed the original list of files once you started incrementing your counter, so if that is the case, you can just keep reusing exist to count your existing filenames.

Related

Delete file which cannot delete because is used by another process

I know you are going to tell me that this question is stupid but I really cannot find a solution to delete my file.
In fact, I open a connection to an .add file (same style as SQL in a way) but afterwards I cannot delete it because it is used by another process which is the process of my application.
While doing some research on the internet I was able to find the solution to kill the process however if I do this it stops my application. Then I also found the GC collect but it doesn't work : /
There is my code :
try
{
string idClient = "parfilux";
string tableName = "ACT,ACF";
string dataSourceDBF = "C:/winbooks/data/parfilux";
string path = dataSourceDBF ;
string pathTemp = dataSourceDBF + "/CopyTempWebService/";
if (!Directory.Exists(pathTemp)) Directory.CreateDirectory(pathTemp);
string addFile = path + "/" + idClient + ".add";
File.Copy(addFile, pathTemp + idClient.ToUpper() + ".add");
File.Copy(addFile.Replace(".add", ".ai"), pathTemp + "/" + idClient.ToUpper() + ".ai");
File.Copy(addFile.Replace(".add", ".am"), pathTemp + "/" + idClient.ToUpper() + ".am");
tableName = tableName.Replace(" ", "");
string[] tables = tableName.Split(',');
string pathTable = null;
foreach (string tab in tables)
{
pathTable = path + "/" + idClient.ToUpper() + "_" + tab.ToUpper() + ".dbf";
File.Copy(pathTable, pathTemp + idClient.ToUpper() + "_" + tab.ToUpper() + ".dbf");
File.Copy(pathTable.Replace(".dbf", ".cdx"), pathTemp + idClient.ToUpper() + "_" + tab.ToUpper() + ".cdx");
}
AdsConnection dbfCo;
//dbfCo.Close();
dbfCo = new AdsConnection(#"data Source=" + dataSourceDBF + "/CopyTempWebService/" + idClient + ".add;User ID=admin;Password=;ServerType=Local;ReadOnly=true;pooling=true;TrimTrailingSpaces=true;ShowDeleted=TRUE;TableType=CDX;LockMode=COMPATIBLE");
dbfCo.Open();
//QueryDataDBF(tableName, idClient, false);
dbfCo.Close();
dbfCo.Dispose();
//Process process = Process.GetCurrentProcess();
//Console.WriteLine(process.MainModule);
//process.Kill();
//foreach(Process pro in process)
//{
// Console.WriteLine(pro);
// if(pro.ProcessName == pathTemp + idClient.ToUpper() + ".add")
// {
// pro.Kill();
// }
//}
//System.GC.Collect();
//System.GC.WaitForPendingFinalizers();
//File.Delete(pathTemp + idClient.ToUpper() + ".add");
Directory.Delete(dataSourceDBF + "/CopyTempWebService", true);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
I open a connection to my .add with the Advantage Data Provider library.
Do you have any idea to fix this problem ? thank you in advance ;)

C# Out of Memory writing to CSV

I've been working on a small program for a friend of mine who has a very large file which I read into the a datagridview > Modify data > export to csv. I managed to make everything work relevatively well until recently when he asked me to make some changes to the way the data is exported. For some reason, I am getting an Out of Memory exception when running this function.
private void ExportData(int fileNum = 1, int rowCount = 0)
{
int lastRow = rowCount;
if (!Directory.Exists(ExportPath + dataFilePath.Name))
Directory.CreateDirectory(ExportPath + dataFilePath.Name);
StreamWriter sw = new StreamWriter(ExportPath + dataFilePath.Name + #"\" + dataFilePath.Name + "_" + fileNum + ".csv");
//var headers = dataGridView1.Columns.Cast<DataGridViewColumn>();
//sw.WriteLine(string.Join(",", headers.Select(column => "\"" + column.HeaderText + "\"").ToArray()));
sw.WriteLine("Unit,UPC,Brand,Vendor,List Cost,QTY,Price,Description,Attribute 1,Attribute 2," +
"Descriptor 1,Descriptor 2,Descriptor 3,Descriptor 4,Descriptor 5,Descriptor 6,Descriptor 7,Descriptor 8");
for (int i = 0; i < 50000; i++)
{
rowCount = lastRow + i;
if (rowCount >= dataGridView1.RowCount)
break;
var cells = dataGridView1.Rows[rowCount].Cells.Cast<DataGridViewCell>();
sw.WriteLine(string.Join(",", cells.Select(cell => "\"" + cell.Value + "\"").ToArray()));
}
sw.Close();
sw.Dispose();
lastRow = rowCount + 1;
if (lastRow < dataGridView1.RowCount - 1)
ExportData(fileNum + 1, lastRow);
else
{
progressBar1.BeginInvoke(new MethodInvoker(delegate {
progressBar1.Style = ProgressBarStyle.Blocks;
button_OpenDataFile.Enabled = true;
button_ConvertFromRaw.Enabled = true;
button_exportLS.Enabled = true;
Console.WriteLine("[Main] Export complete.");
}));
}
}
var cells = dataGridView1.Rows[rowCount].Cells.Cast<DataGridViewCell>();
seems to be the line the error occurs on.
Could anyone provide any insight into what Im doing wrong?
Thank you!
Do this experiment: convert your code to a loop, instead of using recursion:
private void ExportData()
{
//You only need to do this once, take it out of the loop.
if (!Directory.Exists(ExportPath + dataFilePath.Name))
Directory.CreateDirectory(ExportPath + dataFilePath.Name);
var fileNum = 0;
var rowCount = 0;
while (rowCount < dataGridView1.RowCount)
{
fileNum = fileNum + 1;
using (StreamWriter sw = new StreamWriter(ExportPath + dataFilePath.Name + #"\" + dataFilePath.Name + "_" + fileNum + ".csv")
{
sw.WriteLine("Unit,UPC,Brand,Vendor,List Cost,QTY,Price,Description,Attribute 1,Attribute 2," +
"Descriptor 1,Descriptor 2,Descriptor 3,Descriptor 4,Descriptor 5,Descriptor 6,Descriptor 7,Descriptor 8");
for (int i = 0; i < 50000; i++)
{
rowCount = rowCount + 1;
if (rowCount >= dataGridView1.RowCount)
break;
var cells = dataGridView1.Rows[rowCount].Cells.Cast<DataGridViewCell>();
sw.WriteLine(string.Join(",", cells.Select(cell => "\"" + cell.Value + "\"").ToArray()));
}
} //sw.Close() and sw.Dispose() not needed because of the 'using'. You may want to do sw.Flush().
}
//The 'else' part of your original recursive method
progressBar1.BeginInvoke(new MethodInvoker(delegate {
progressBar1.Style = ProgressBarStyle.Blocks;
button_OpenDataFile.Enabled = true;
button_ConvertFromRaw.Enabled = true;
button_exportLS.Enabled = true;
Console.WriteLine("[Main] Export complete.");
}));
}
Does the error go away? Probably yes. Recursion uses a lot of memory in a stack and does not release it until the end of the recursion, when going up the stack again. The line where you get the error just happens to try and add to memory the contents of a whole line in your csv file. That may be the last drop that causes the out of memory exception if the memory is already almost full with the recursion stack.
I removed some accumulator vars that seemed redundant, I hope I didn't mess up with the ranges of the loops.
I removed the CreateDirectory from the loop, and added a using statement for the StreamWriter. I don't think those were the reasons for your error, as the directory was created only once, and you were disposing the StreamWriter before the recursive call, but anyway if you want to confirm it you can try undoing those changes in the non-recursive code one by one, and see if the error happens again.
So I figured it out.
I guess iterating through the datagridview just isn't the way to go. Instead, I just exported the data using my data source. It goes a hell of a lot faster... down from 2 minutes to about 2 seconds.
Thank you, everyone, for the help!
private void ExportData()
{
//You only need to do this once, take it out of the loop.
if (!Directory.Exists(ExportPath + dataFilePath.Name))
Directory.CreateDirectory(ExportPath + dataFilePath.Name);
var fileNum = 0;
var rowCount = 0;
while (rowCount < dataGridView1.RowCount)
{
fileNum = fileNum + 1;
using (StreamWriter sw = new StreamWriter(ExportPath + dataFilePath.Name + #"\" + dataFilePath.Name + "_" + fileNum + ".csv"))
{
sw.WriteLine("Unit,UPC,Brand,Vendor,List Cost,QTY,Price,Description,Attribute 1,Attribute 2" +
"Descriptor 1,Descriptor 2,Descriptor 3,Descriptor 4,Descriptor 5,Descriptor 6,Descriptor 7,Descriptor 8");
for (int i = 0; i < 50000; i++)
{
rowCount = rowCount + 1;
if (rowCount >= dataGridView1.RowCount)
break;
var s = new string[]
{
"\"" + DATA[rowCount].Unit + "\"",
"\"" + DATA[rowCount].UPC + "\"",
"\"" + DATA[rowCount].Brand + "\"",
"\"" + DATA[rowCount].Vendor + "\"",
"\"" + DATA[rowCount].List_Cost + "\"",
"\"" + DATA[rowCount].Quantity.ToString() + "\"",
"\"" + DATA[rowCount].Price + "\"",
"\"" + DATA[rowCount].Description + "\"",
"\"" + DATA[rowCount].Attribute_1 + "\"",
"\"" + DATA[rowCount].Attribute_2 + "\"",
"\"" + DATA[rowCount].Descriptor_1 + "\"",
"\"" + DATA[rowCount].Descriptor_2 + "\"",
"\"" + DATA[rowCount].Descriptor_3 + "\"",
"\"" + DATA[rowCount].Descriptor_4 + "\"",
"\"" + DATA[rowCount].Descriptor_5 + "\"",
"\"" + DATA[rowCount].Descriptor_6 + "\"",
"\"" + DATA[rowCount].Descriptor_7 + "\"",
"\"" + DATA[rowCount].Descriptor_8 + "\""
};
sw.WriteLine(string.Join(",", s));
sw.Flush();
}
} //sw.Close() and sw.Dispose() not needed because of the 'using'. You may want to do sw.Flush().
}
//The 'else' part of your original recursive method
progressBar1.BeginInvoke(new MethodInvoker(delegate
{
progressBar1.Style = ProgressBarStyle.Blocks;
button_OpenDataFile.Enabled = true;
button_ConvertFromRaw.Enabled = true;
button_exportLS.Enabled = true;
Console.WriteLine("[Main] Export complete.");
}));
}'

C# can't delete file after streamreader use

I can't seem to be able to delete files after a streamreader use, with a
"file can't be accessed because file is in use"
error in C#.
I may miss something but I don't know what, here is the code :
fileEntries = from fullFilename
in Directory.EnumerateFiles(#"Data\csv\pending")
select Path.GetFileName(fullFilename);
i = 1;
foreach (string file in fileEntries)
{
if(i == 1)
{
folder = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName) + #"\Data\csv\done";
using (System.IO.FileStream fs = System.IO.File.Create(folder + #"\create-user.csv"))
{
}
using (System.IO.StreamWriter files = new System.IO.StreamWriter(folder + #"\create-user.csv", true))
{
files.WriteLine(",; prenom; nom; username; pasword; email; question; reponse; GroupID");
}
string curfile = #"\create-user-archive.csv";
if(!(File.Exists(folder + curfile)))
{
using (System.IO.StreamWriter files = new System.IO.StreamWriter(folder + #"\create-user-archive.csv", true))
{
files.WriteLine(",; prenom; nom; username; pasword; email; question; reponse; GroupID");
}
}
}
folder = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName) + #"\Data\csv\pending";
sb = new StringBuilder();
filef = new System.IO.StreamReader(folder + #"\create-user-" + i + ".csv");
line = filef.ReadLine();
while ((line = filef.ReadLine()) != null)
{
sb = new StringBuilder();
sb.AppendLine(line.Substring(0, line.Length));
folder = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName) + #"\Data\csv\done";
using (System.IO.StreamWriter files = new System.IO.StreamWriter(folder + #"\create-user.csv", true))
{
files.WriteLine(",; " + sb.ToString().Split(';')[1] + ";" + sb.ToString().Split(';')[2] + ";" + sb.ToString().Split(';')[1] + "." + sb.ToString().Split(';')[2] + ";" + GenerateToken(6) + ";" + sb.ToString().Split(';')[3] + ";" + "1" + ";" + "1");
}
folder = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName) + #"\Data\csv\done";
using (System.IO.StreamWriter files = new System.IO.StreamWriter(folder + #"\create-user-archive.csv", true))
{
files.WriteLine(",; " + sb.ToString().Split(';')[1] + ";" + sb.ToString().Split(';')[2] + ";" + sb.ToString().Split(';')[1] + "." + sb.ToString().Split(';')[2] + ";" + GenerateToken(6) + ";" + sb.ToString().Split(';')[3] + ";" + "1" + ";" + "1");
}
}
i++;
sourceFile = System.IO.Path.Combine(#"Data\csv\pending", file);
File.Delete(sourceFile);
}
shouldn't the file stop being in use after the streamreader is finished? I tried using a function that waits until the file is unlocked to delete the file, but it is infinite, which means there is a never ending process that I must stop, but I don't see which one.
You need to close filef.
Wrapping the code in a using statement will automatically close the reader
using ( System.IO.StreamReader filef = new System.IO.StreamReader(folder + #"\create-user-" + i + ".csv") {
....yourcodehere
}
Alternatively, call filef.Close() when you are done with it (before you delete the file)
You have to close the streams you create to dispose the system resources. You can either use the Close method or the using pattern, as the classes implemented IDisposable interface. I would recommend you to the second option.
May have a look to this post: https://stackoverflow.com/a/707339/6244709
You will need to call the following;
filef.Close();
This would go before your delete;
while ((line = filef.ReadLine()) != null)
{
sb = new StringBuilder();
sb.AppendLine(line.Substring(0, line.Length));
folder = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName) + #"\Data\csv\done";
using (System.IO.StreamWriter files = new System.IO.StreamWriter(folder + #"\create-user.csv", true))
{
files.WriteLine(",; " + sb.ToString().Split(';')[1] + ";" + sb.ToString().Split(';')[2] + ";" + sb.ToString().Split(';')[1] + "." + sb.ToString().Split(';')[2] + ";" + GenerateToken(6) + ";" + sb.ToString().Split(';')[3] + ";" + "1" + ";" + "1");
}
folder = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName) + #"\Data\csv\done";
using (System.IO.StreamWriter files = new System.IO.StreamWriter(folder + #"\create-user-archive.csv", true))
{
files.WriteLine(",; " + sb.ToString().Split(';')[1] + ";" + sb.ToString().Split(';')[2] + ";" + sb.ToString().Split(';')[1] + "." + sb.ToString().Split(';')[2] + ";" + GenerateToken(6) + ";" + sb.ToString().Split(';')[3] + ";" + "1" + ";" + "1");
}
}
i++;
sourceFile = System.IO.Path.Combine(#"Data\csv\pending", file);
filef.Close();
File.Delete(sourceFile);

Problem with C# File IO in Winforms

I'm having issues with a C# Winforms file IO. The code complies just fine, but then it returns errors on execution.
The output code is here:
private void saveData()
{
string fullPath = System.Environment.GetEnvironmentVariable(#"%MyDocuments%\HellsingRPG\");
StreamWriter writer = new StreamWriter(fullPath + textBox2.Text + ".txt");
writer.WriteLine(textBox1.Text + "," + textBox2.Text + "," + textBox3.Text + "," + textBox4.Text + "," + comboBox1.SelectedText + "," +
numericUpDown25.Value + "," + numericUpDown1.Value + "," + numericUpDown2.Value + "," + numericUpDown3.Value + "," + numericUpDown4.Value + "," +
numericUpDown5.Value + "," + numericUpDown6.Value + "," + numericUpDown7.Value + "," + numericUpDown8.Value + "," + numericUpDown9.Value + "," +
numericUpDown10.Value + "," + numericUpDown11.Value + "," + numericUpDown12.Value + "," + numericUpDown13.Value + "," + numericUpDown14.Value
+ "," + numericUpDown15.Value + "," + numericUpDown16.Value + "," + numericUpDown17.Value + "," + numericUpDown18.Value + "," +
numericUpDown19.Value + "," + numericUpDown20.Value + "," + numericUpDown21.Value + "," + numericUpDown22.Value);
writer.Close();
}
And the code to load the data is here:
private void loadData()
{
Stream myStream = null;
OpenFileDialog openFileDialog1 = new OpenFileDialog();
openFileDialog1.InitialDirectory = System.Environment.GetEnvironmentVariable(#"%MyDocuments%\HellsingRPG\");
openFileDialog1.Filter = "txt files (*.txt)|*.txt|All files (*.*)|*.*";
openFileDialog1.FilterIndex = 2;
openFileDialog1.RestoreDirectory = true;
if (openFileDialog1.ShowDialog() == DialogResult.OK)
{
try
{
if ((myStream = openFileDialog1.OpenFile()) != null)
{
using (myStream)
{
List<string> myData = parseCSV(System.Convert.ToString(myStream));
textBox1.Text = myData[0];
textBox2.Text = myData[1];
textBox3.Text = myData[3];
textBox4.Text = myData[4];
comboBox1.SelectedText = myData[5];
numericUpDown25.Value = System.Convert.ToDecimal(myData[6]);
numericUpDown1.Value = System.Convert.ToDecimal(myData[7]);
numericUpDown2.Value = System.Convert.ToDecimal(myData[8]);
numericUpDown3.Value = System.Convert.ToDecimal(myData[9]);
numericUpDown4.Value = System.Convert.ToDecimal(myData[10]);
numericUpDown5.Value = System.Convert.ToDecimal(myData[11]);
numericUpDown6.Value = System.Convert.ToDecimal(myData[12]);
numericUpDown7.Value = System.Convert.ToDecimal(myData[13]);
numericUpDown8.Value = System.Convert.ToDecimal(myData[14]);
numericUpDown9.Value = System.Convert.ToDecimal(myData[15]);
numericUpDown10.Value = System.Convert.ToDecimal(myData[16]);
numericUpDown11.Value = System.Convert.ToDecimal(myData[17]);
numericUpDown12.Value = System.Convert.ToDecimal(myData[18]);
numericUpDown13.Value = System.Convert.ToDecimal(myData[19]);
numericUpDown14.Value = System.Convert.ToDecimal(myData[20]);
numericUpDown15.Value = System.Convert.ToDecimal(myData[21]);
numericUpDown16.Value = System.Convert.ToDecimal(myData[22]);
numericUpDown17.Value = System.Convert.ToDecimal(myData[23]);
numericUpDown18.Value = System.Convert.ToDecimal(myData[24]);
numericUpDown19.Value = System.Convert.ToDecimal(myData[25]);
numericUpDown20.Value = System.Convert.ToDecimal(myData[26]);
numericUpDown21.Value = System.Convert.ToDecimal(myData[27]);
numericUpDown22.Value = System.Convert.ToDecimal(myData[28]);
}
}
}
catch (Exception ex)
{
MessageBox.Show("Error: Could not read file from disk. Original error: " + ex.Message);
}
}
}
And that compiles just fine. But when I use it, I get the following errors:
"Could not find file "C:\Users\collmark\Documents\Visual Studio
2015\Projects\WindowsFormsApplication1\WindowsFormsApplication1\bin\Release\System.IO.Filestream".
"Error: Could not read file from disk. Original error: Index out of
range. Must be non-negative and less than the size of the collection.
Parameter name: index."
Thanks
your save data seems to save 22 fields while the read expects 28.
I suspect the myData object does not contain the fields index you are trying to read, hence index out of range.
do yourself a favour when printing exception data don't limit yourself to the message but print the whole stack trace, it will tell you which line is faulty giving you a hint at the actual problem.
MessageBox.Show("Error: Could not read file from disk. Original error: " + ex.ToString());

check for duplicate filename when copying files in C#

I want to copy files to a directory and rename to a particular format. but if the filename already exists, it should append {1}, {2} or {3} before the file extension.
My code renamed and copied the file and named it to my desired format say filename.pdf, when it checked for duplicate it renamed it to filename1.pdf. but when it tried copying again, it gave an error "file already exists" but i wanted it to have named it to filename02.pdf.
Pls can someone help me out.
Here is the code i have written so far.
{
string fileSource, filesToCopy, target, nextTarget;
string sourceDir = #"C:\HCP_PDFs";
string destinationDir = #"C:\RenamedHcpPdfs";
DirectoryInfo di = new DirectoryInfo(destinationDir);
// create the directory if it dosnt exist
if (!Directory.Exists(destinationDir))
{
Directory.CreateDirectory(destinationDir);
}
foreach (string myFiles in lstBoxFilenames.Items)
{
filesToCopy = myFiles;
fileSource = Path.Combine(sourceDir, filesToCopy);
//Extract only HCP Name by splitting , removing file Extension and removing HCP ID
string hcp = filesToCopy.Split('_')[0];
string hcpCd = filesToCopy.Split('_')[1];
string hcpID = filesToCopy.Split('_')[2];
string hcpName = String.Format((filesToCopy.Split('_')[3]).Replace(".pdf", ""));
//combine the HCP ID, HCP name and date
target = Path.Combine(destinationDir, hcp + "{" + hcpCd + "~" + hcpID + "}" + hcpName + "{2013_03_14}" + ".pdf");
// if file exists in directory then rename and increment filename by 1
int i = +1 ;
nextTarget = Path.Combine(destinationDir, hcp + "{" + hcpCd + "~" + hcpID + "}" + hcpName + "{2013_03_14}" + i + ".pdf");
if (File.Exists(target))
{
File.Copy(fileSource, nextTarget);
break;
}
//if file does not exist, rename
else
{
File.Copy(fileSource, target);
}
}
}
Try this :
string target = Path.Combine(destinationDir, hcp + "{" + hcpCd + "~" + hcpID + "}" + hcpName + "{2013_03_14}.pdf");
while(File.Exists(target))
{
i++;
target = Path.Combine(destinationDir, hcp + "{" + hcpCd + "~" + hcpID + "}" + hcpName + "{2013_03_14}" + i + ".pdf");
}
File.Copy(fileSource, target);
break;
Do this
while(File.Exists(target))
{i++;}
now declare your target path.

Categories

Resources