How to delete a specific line from a text file in Delphi
Asked Answered
L

6

11

I have a text file with user information stored in it line by line. Each line is in the format: UserID#UserEmail#UserPassword with '#' being the delimiter.

I have tried to use this coding to perform the task:

var sl:TStringList;
begin
  sl:=TStringList.Create;
  sl.LoadFromFile('filename');
  sl.Delete(Index);
  sl.SaveToFile('filename');
  sl.free;
end;

But I'm not sure what to put in the "index" space.

Is there any way I can receive the User ID as input and then delete the line of text from the text file that has this user ID in? Any help would be appreciated.

Lincoln answered 18/8, 2016 at 15:10 Comment(3)
I guess you have to loop through each line of sl and compare it. When you found a match remember the index your on and pass it to sl.Delete()Explosion
You are not asking about deleting a line. You already know how to do that. You are asking about finding a line. Read each line, parse it, find the line(s) that match. What part can't you do?Facula
The term Index, in this context, refers to the Line Number of the Item within the list. TStringLists are 0 based, so the first line is 0, 2nd is 1, etc. The answers below help you, and can be modified to suit your own circumstances. Also, be aware there is a bug that causes Delimiters to not function correctly (certainly in Delphi 7), don't rely on it. Don't load a CSV file and rely on Delimiter Functions.Immiscible
P
6

You can set the NameValueSeparator to # then use IndexOfName to find the user, as long as the username is the first value in the file.

sl.NameValueSeparator := '#';
Index := sl.IndexOfName('455115')

So in your example, like so

var sl:TStringList;
begin
  sl:=TStringList.Create;
  sl.LoadFromFile('filename');
  sl.NameValueSeparator := '#';
  Index := sl.IndexOfName('455115')
  if (Index  <> -1) then
  begin
      sl.Delete(Index);
      sl.SaveToFile('filename');
  end;
  sl.free;
end;

This may be slow on large files as IndexOfName loops though each line in the TStringList and checks each string in turn until it finds a match.

Disclaimer: Tested/ works with Delphi 2007, Delphi 7 may be diffrent.

Pollock answered 18/8, 2016 at 15:31 Comment(6)
This algorithm works, but once the line has been deleted it leaves an extra open line in the text file which is problematic when it comes to populating an array of users from data in the text file. Is there any way to execute this algorithm but incorporate a method to delete the extra line that is left in the text file?Lincoln
@MarkvanHeerden: What extra line are you referring to? Please be more specific. Delete() removes the entire line, it does not write it out as an empty line. The only possible place I could think an empty line would appear is at the very end of the file, but SaveToFile() should not be writing an empty line there, unless the last entry in the list is empty to begin with. Are you SURE no entries in the list are empty to begin with?Sherrard
The empty line that appears is at the end of the file. And you are correct, the last entry is an empty line to begin with. This is due to when adding a new user I use the Writeln() statement and add a #13 at the end to ensure that it writes to a new line every time. Is there a way i can rework to avoid getting the extra line?Lincoln
@MarkvanHeerden simply do not append a #13 manually. WriteLn() already outputs a line break for you. That is what makes it different than Write().Sherrard
This won't work for, say UserEmail or UserPassword. And it is not able to delete multiple items either.Torquemada
@RudyVelthuis is correct, the example has 3 items, username, email and password. Name/Value supports only 2. Although the questioner said "It works" but I'm not convinced without modification.Immiscible
T
1

I don't see why so many people make this so hard. It is quite simple:

function ShouldDeleteLine(const UserID, Line: string): Boolean;
begin    
  // Remember: Pos(Needle, Haystack)
  Result := Pos(UserID + '#', Line) = 1; // always 1-based!
end;

procedure DeleteLinesWithUserID(const FileName, UserID: string);
var
  SL: TStringList;
  I: Integer;
begin
  if not FileExists(FileName) then
    Exit;

  SL := TStringList.Create;
  try
    SL.LoadFromFile(FileName); // Add exception handling for the 
                               // case the file does not load properly.

    // Always work backward when deleting items, otherwise your index
    // may be off if you really delete.
    for I := SL.Count - 1 downto 0 do
      if ShouldDeleteLine(SL[I], UserID) then
      begin
        SL.Delete(I);
        // if UserID is unique, you can uncomment the following line.
        // Break;
      end;
    SL.SaveToFile(FileName);
  finally
    SL.Free;
  end;
end;

As Arioch'The says, if you save to the same file name, you risk losing your data when the save fails, so you can do something like

SL.SaveToFile(FileName + '.dup');
if FileExists(FileName + '.old') then
  DeleteFile(FileName + '.old');
RenameFile(FileName, FileName + '.old');
RenameFile(FileName + '.dup', FileName);

That keeps a backup of the original file as FileName + '.old'.

Explanations

Working backward

Why work backward? Because if you have the following items

A B C D E F G
      ^

And you delete the item at ^, then the following items will shift downward:

A B C E F G
      ^

If you iterate forward, you will now point to

A B C E F G
        ^

and E is never examined. If you go backward, then you will point to:

A B C E F G
    ^

Note that E, F and G were examined already, so now you will indeed examine the next item, C, and you won't miss any. Also, if you go upward using 0 to Count - 1, and delete, Count will become one less and at the end, you will try to access past the boundary of the list. This can't happen if you work backwards using Count - 1 downto 0.

Using + '#'

If you append '#' and test for Pos() = 1, you will be sure to catch the entire UserID up to the delimiter, and not a line with a user ID that only contains the UserID you are looking for. IOW, if UserID is 'velthuis', you don't want to delete lines like 'rudyvelthuis#rvelthuis01#password' or 'velthuisresidence#vr#password2', but you do want to delete 'velthuis#bla#pw3'.

E.g. when looking for a user name, you look for '#' + UserName + '#' for the same reason.

Torquemada answered 18/8, 2016 at 20:45 Comment(10)
I see the second item is an email and not a user name. Oh well, with this iPad editing is a bit awkward and the principle is the same anyway.Torquemada
this risks destroying all user data on failed save attemptHolliholliday
Sure. I could write to a different name, delete the original file and then rename to the original name. Not part of the question, IMO. The question is how to delete the line(s).Torquemada
but "how to delete line on disk " not "how delete line in memory "Holliholliday
See the code in the original question. The question was how to delete the line with the given user ID.Torquemada
that is orthogonal issue, the criterion of the line(specific prefix) and the location of the line(disk or RAM)Holliholliday
@Arioch'The: That is not how I read it. Anyway, does it matter?Torquemada
Well, it is better to give good examples than bad, and at very least to mention issues that novices tend to overlook.Holliholliday
Yes, and I could also have added exception handling and whatnot. It is good enough if the question is answered, IMO.Torquemada
And you did it, by adding try-finally clauses, that topic starter and two other answers failed to do.Holliholliday
J
0

There is the only way to actually "delete a line from the text file" - that is to create a new file with changed content, to REWRITE it.

So you better just do it explicitly.

And don't you forget about protecting from errors. Your current code might just destroy the file and leak memory, if any error occurs...

var sl: TStringList;
    s, prefix: string;
    i: integer; okay: Boolean;
    fs: TStream;

begin
  prefix := 'UserName' + '#';
  okay := false;

  fs := nil;
  sl:=TStringList.Create;
  Try   /// !!!!
    sl.LoadFromFile('filename');
    fs := TFileStream.Create( 'filename~new', fmCreate or fmShareExclusive );

    for i := 0 to Prev(sl.Count) do begin
      s := sl[ i ];

      if AnsiStartsStr( prefix, Trim(s) ) then
         continue;  // skip the line - it was our haunted user

      s := s + ^M^J;  // add end-of-line marker for saving to file

      fs.WriteBuffer( s[1], length(s)*SizeOf(s[1]) );  
    end; 
  finally 
    fs.Free;
    sl.Free;
  end;

  // here - and only here - we are sure we successfully rewritten 
  // the fixed file and only no are able to safely delete old file
  if RenameFile( 'filename' , 'filename~old') then
     if RenameFile( 'filename~new' , 'filename') then begin
        okay := true; 
        DeleteFile( 'filename~old' ); 
     end;

  if not okay then ShowMessage(' ERROR!!! ');
end;

Note 1: See if check for username should be case-sensitive or case-ignoring:

Note 2: in Delphi 7 SizeOf( s[1] ) is always equal to one because string is an alias to AnsiString. But in newer Delphi version it is not. It might seems tedious and redundant - but it might save a LOT of headache in future. Even better would be to have a temporary AnsiString type variable like a := AnsiString( s + ^m^J ); fs.WriteBuffer(a[1],Length(a));

Jardiniere answered 18/8, 2016 at 15:57 Comment(2)
Will having a generic name for the old text file and new text file not result in error when running this algorithm more than once?Lincoln
@MarkvanHeerden 1) no - because after successful ending of this algo the temporary file is deleted and only the file with original name remains, like before the start. 2) more importantly, those constants are obviously stub, which would be replaced in real program. I could add the code to generate random temporary file name - but it would add too much detail to this skeleton, blurring the intention. More so, in production I'd maybe even go with auto-delete files, that're not available in Delphi RTL, yet again I'd rather not pollute this brief simplified demo with all those bells and whistlesHolliholliday
I
0

So far everyone has been suggesting the use for a For..Then Loop but can I suggest a Repeat..While.

The traditional For..Loop is a good option but could be inefficient if you have a long list of Usernames (they are usually unique). Once found and deleted the For Loop continues until the end of the list. That's ok if you have a small list but if you have 500,000 Usernames and the one you want is at position 10,000 there is no reason to continue beyond that point.

Therefore, try this.

    Function DeleteUser(Const TheFile: String; Const TheUserName: String): Boolean;
    Var
      CurrentLine: Integer;
      MyLines: TStringlist;
      Found: Boolean;
      Eof: Integer;

    Begin

      MyLines := TStringlist.Create;
      MyLines.LoadFromFile(TheFile);

      CurrentLine := 0;
      Eof := Mylines.count - 1; 

      Found := false;

      Repeat 

        If Pos(UpperCase(TheUserName), UpperCase(MyLines.Strings[CurrentLine])) = 1 Then
        Begin

         MyLines.Delete(CurrentLine);
          Found := True;

        End;

        Inc(CurrentLine);

      Until (Found) Or (CurrentLine = Eof); // Jump out when found or End of File

      MyLines.SaveToFile(TheFile);
      MyLines.Free;

      result := Found;
    End;

Once called the function returns True or False indicating the Username was deleted or not.

If Not DeleteUsername(TheFile,TheUsername) then
ShowMessage('User was not found, what were you thinking!');
Immiscible answered 20/8, 2016 at 2:6 Comment(1)
While I love the "purity" of good programming using repeat/until or while to exit from a loop, keep in mind that in Delphi (like in "C") you can use "break" (to jump out of the loop) or "continue" to go back at the beginning. While those two could look "almost like a goto/label", they are actually cleaner to read and faster to execute than creating a flag and checking for it.Mallorie
C
0

Just for fun, here's a compact solution, which I like for its readability.

const fn = 'myfile.txt';

procedure DeleteUser(id: integer);
var s:string; a:TStringDynArray;
begin
  for s in TFile.ReadAllLines(fn) do
    if not s.StartsWith(id.ToString + '#') then
      a := a + [s];

  TFile.WriteAllLines(fn, a);
end;

Obviously it's not the most efficient solution. This could run faster by not appending single items to the array, or by caching the search string.

And to search for other fields, you could use s.split(['#'])[0] to find the username, s.split(['#'])[1] for email, etc.

Cicelycicenia answered 20/8, 2016 at 3:14 Comment(0)
C
0

For those who like one-liners. This works too:

const fn = 'users.txt';

procedure DeleteUserRegExp(id: string);
begin
  TFile.WriteAllText(fn,TRegEx.Replace(TFile.ReadAllText(fn),''+id+'\#.*\r\n',''))
end;

Explanation

  1. It loads the content of a file into a string.
  2. The string is sent to TRegEx.Replace
  3. The regular expression searches for the username followed by the hash sign, then any character, and then a CRLF. It replaces it with an empty string.
  4. The resulting string is then written to the original file

This is just for fun though, because I saw long code where I thought that this would be possible with a single line of code.

Cicelycicenia answered 20/8, 2016 at 3:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.