1 {
2  ***************************************************************************
3  *                                                                         *
4  *   This source is free software; you can redistribute it and/or modify   *
5  *   it under the terms of the GNU General Public License as published by  *
6  *   the Free Software Foundation; either version 2 of the License, or     *
7  *   (at your option) any later version.                                   *
8  *                                                                         *
9  *   This code is distributed in the hope that it will be useful, but      *
10  *   WITHOUT ANY WARRANTY; without even the implied warranty of            *
11  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU     *
12  *   General Public License for more details.                              *
13  *                                                                         *
14  *   A copy of the GNU General Public License is available on the World    *
15  *   Wide Web at <http://www.gnu.org/copyleft/gpl.html>. You can also      *
16  *   obtain it by writing to the Free Software Foundation,                 *
17  *   Inc., 51 Franklin Street - Fifth Floor, Boston, MA 02110-1335, USA.   *
18  *                                                                         *
19  ***************************************************************************
20 }
21 (*
22 Modified by Kevin Jesshope <KevinOfOz@gmail.com> 15 Mar 2020
23 - Owner and Category now support basic quoted values
24   -c'Lazarus ToDoList'
25 - Select Token style from dialog. #todo is normal (unchecked) todo is alernate (checked)
26 - Save Owner, Category and normal/alt selection to XMLPropStorage
27 - Move (some) non-presentation code to ToDoListCore
28 - Add Note type to ToDo and Done types
29 *)
30 
31 unit ToDoListCore;
32 
33 {$mode objfpc}{$H+}
34 
35 interface
36 
37 uses
38   // FCL, RTL
39   Classes, SysUtils, StrUtils, Laz_AVL_Tree,
40   // LCL
41   LCLType, LclIntf, Controls, Dialogs, ComCtrls,
42   // LazUtils
43   LazFileUtils, LazStringUtils, LazFileCache, LazLoggerBase,
44   // Codetools
45   CodeToolManager, FileProcs, CodeCache, BasicCodeTools,
46   // IDEIntf
47   PackageIntf, ProjectIntf,
48   // ToDoList
49   ToDoListStrConsts;
50 
51 type
52   TToDoType = (tdToDo, tdDone, tdNote);
53   TTokenStyle = (tsNormal, tsAlternate);
54 
55 const
56   LIST_INDICATORS : array [TToDoType] of string
57       = ('ToDo', 'Done', 'Note');
58 
59   // Value names of the various parts of the todo entry
60   OWNER_PART_NAME = 'Owner';
61   CATEGORY_PART_NAME = 'Category';
62   PRIORITY_PART_NAME = 'Priority';
63   TEXT_PART_NAME = 'Text';
64 
65 
66 type
67   TTLScannedFile = class;
68 
69   { TTodoItem: Class to hold TODO item information }
70 
71   TTodoItem = class(TObject)
72   private
73     FCategory: string;
74     FToDoType:TToDoType;
75     FTokenStyle:TTokenStyle;
76     FFilename: string;
77     FLineNumber: integer;
78     FOwner: string;
79     FPriority: integer;
80     FText: string;
81     FTLFile: TTLScannedFile;
GetQuotedCategorynull82     function GetQuotedCategory: string;
GetQuotedOwnernull83     function GetQuotedOwner: string;
GetAsCommentnull84     function GetAsComment: string;
GetAsStringnull85     function GetAsString: string;
QuotedStrnull86     function QuotedStr(const aSrc: string; const aQuote: char): string;
87   public
88     constructor Create(aTLFile: TTLScannedFile);
89     property TLFile: TTLScannedFile read FTLFile;
90     property Category: string read FCategory write FCategory;
91     property QuotedCategory:string read GetQuotedCategory;
92     property TokenStyle: TTokenStyle read FTokenStyle write FTokenStyle;
93     property ToDoType:TToDoType read FToDoType write FToDoType;
94     property LineNumber: integer read FLineNumber write FLineNumber;
95     property Filename: string read FFilename write FFilename;
96     property Owner: string read FOwner write FOwner;
97     property QuotedOwner:string read GetQuotedOwner;
98     property Priority: integer read FPriority write FPriority;
99     property Text: string read FText write FText;
100     property AsString: string read GetAsString;
101     property AsComment: string read GetAsComment;
102   end;
103 
104   { TTLScannedFiles }
105 
106   TTLScannedFile = class
107     FItems: TFPList;// list of TTodoItem
108   private
GetCountnull109     function GetCount: integer;
GetItemsnull110     function GetItems(Index: integer): TTodoItem;
111   public
112     Filename: string; // = Tool.MainFilename
113     CodeChangeStep: integer; // = Tool.Scanner.ChangeStep
114     destructor Destroy; override;
115     procedure Clear;
116     procedure Add(aItem: TTodoItem);
117     property Count: integer read GetCount;
118     property Items[Index: integer]: TTodoItem read GetItems; default;
119   end;
120 
121   { TToDoListCore }
122 
123   (* implemented as a class of class procedures so the protected methods can be
124      exposed via a class helper for unit testing purposes *)
125 
126   TToDoListCore = class(TObject)
127   protected
128     class procedure ParseToParts(const aTokenString:string;const aParts:TStrings);
129     class procedure AddToDoItemFromParts(const aParts: TStrings;
130       const aTLFile: TTLScannedFile; const aFileName: string;
131       const aLineNumber: integer; const aToDoType: TToDoType;
132       const aTokenStyle: TTokenStyle);
133   public
134     class procedure CreateToDoItem(aTLFile: TTLScannedFile;
135         const aFileName: string; const aStartComment, aEndComment: string;
136         const aTokenString: string; aLineNumber: Integer);
137     class procedure ExtractToCSV(const aListItems: TListItems; const aFilename: string);
138     class procedure ScanFile(const aFileName: string; const FScannedFiles: TAvlTree
139       );
140   end;
141 
142 
143 implementation
144 
145 const
146   TODO_TOKENS : array [TTokenStyle, TToDoType] of string
147       = (('#todo', '#done', '#note'), ('TODO', 'DONE', 'NOTE'));
148 
CompareAnsiStringWithTLScannedFilenull149 function CompareAnsiStringWithTLScannedFile(Filename, ScannedFile: Pointer): integer;
150 begin
151   Result:=CompareFilenames(AnsiString(Filename),
152                            TTLScannedFile(ScannedFile).Filename);
153 end;
154 
155 { TToDoListCore }
156 
157 type
158   TParseState = (psHunting, psGotDash, psOwnerStart, psOwnerContinue, psCategoryStart,
159   psCategoryContinue, psPriority, psText, psAllDone); { NOTE : Continue state must follow Start state }
160 
161 class procedure TToDoListCore.ParseToParts(const aTokenString: string;
162   const aParts: TStrings);
163 
ParseStateToTextnull164   function ParseStateToText(const aParseState:TParseState):string;
165   begin
166     case aParseState of
167       psOwnerStart, psOwnerContinue:Result := OWNER_PART_NAME;
168       psCategoryStart,psCategoryContinue:Result := CATEGORY_PART_NAME;
169       psPriority:Result := PRIORITY_PART_NAME;
170       psText:Result := TEXT_PART_NAME;
171       else
172         raise Exception.Create(excInvalidParseState);
173     end;
174   end;
175 
176 var
177   lParseState:TParseState;
178   i, lPriorityStart, lBytesRemoved: Integer;
179   lTempStr, lStr: string;
180   lpTemp: PChar;
181 begin
182   lParseState :=psHunting;
183   i := 1;
184   while i <= Length(aTokenString) do
185     begin
186       case lParseState of
187 
188         psHunting:
189           begin
190             case aTokenString[i] of
191               ' ':Inc(i);// look at the next character
192               '-':
193                 begin
194                   lParseState:=psGotDash;
195                   Inc(i);
196                 end;
197               '0'..'9':
198                 begin
199                   lParseState:=psPriority;
200                   lPriorityStart := i;
201                   Inc(i);
202                 end;
203               ':':
204                 begin
205                   lParseState:=psText;
206                   Inc(i);
207                 end;
208               else // Not a special character so it must be the text
209                 lParseState := psText;
210             end;
211           end;
212 
213         psText:
214           begin
215             aParts.Values[ParseStateToText(lParseState)]:= Trim(Copy(aTokenString, i, MaxInt));
216             lParseState := psAllDone;
217           end;
218 
219         psGotDash:
220           begin
221             case LowerCase(aTokenString[i]) of
222               'o':
223                 begin
224                   lParseState:=psOwnerStart;
225                   Inc(i);
226                 end;
227               'c':
228                 begin
229                   lParseState:=psCategoryStart;
230                   Inc(i);
231                 end
232               else // invalid so assume rest is text
233                 begin
234                   lParseState := psText;
235                   Dec(i); // wind back 1 character so we catch the - in the text
236                 end;
237             end;
238           end;
239 
240         psPriority:
241           if (aTokenString[i] < '0') or (aTokenString[i] > '9') then
242             begin
243               aParts.Values[ParseStateToText(lParseState)] := Copy(aTokenString, lPriorityStart, i-lPriorityStart);
244               lParseState := psHunting;
245             end
246           else
247             Inc(i);
248 
249         psOwnerStart, psCategoryStart:
250           begin
251             case aTokenString[i] of
252               '''':// Got a quote so extract
253                 begin
254                   lTempStr := Copy(aTokenString, i, MaxInt);
255                   lpTemp := PChar(lTempStr);
256                   lStr := AnsiExtractQuotedStr(lpTemp, '''');
257                   aParts.Values[ParseStateToText(lParseState)] := lStr;
258                   lBytesRemoved := Length(lTempStr) - Length(lpTemp);
259                   i := i + lBytesRemoved;
260                   lParseState := psHunting;
261                 end;
262               else
263                 begin
264                   lTempStr := aTokenString[i];
265                   Inc(i);
266                   Assert(Succ(psOwnerStart) = psOwnerContinue, 'Succ(psOwnerStart) is not psOwnerContinue.');
267                   Assert(Succ(psCategoryStart) = psCategoryContinue, 'Succ(psCategoryStart) is not psCategoryContinue.');
268                   inc(lParseState); // Assumes Continue is succ to Start
269                 end;
270             end;
271           end;
272 
273         psOwnerContinue,psCategoryContinue:
274           begin
275             if (aTokenString[i] = ' ') or (aTokenString[i] = ':') then
276               begin
277                 aParts.Values[ParseStateToText(lParseState)] := lTempStr;
278                 lParseState:=psHunting;
279               end
280             else
281               begin
282                 lTempStr:=lTempStr + aTokenString[i];
283                 Inc(i);
284               end;
285           end;
286 
287         psAllDone:
288           break;
289 
290       end;
291     end;
292 end;
293 
294 class procedure TToDoListCore.AddToDoItemFromParts(const aParts: TStrings;
295   const aTLFile: TTLScannedFile; const aFileName: string;
296   const aLineNumber: integer; const aToDoType: TToDoType;
297   const aTokenStyle: TTokenStyle);
298 
299 var
300   lNewToDoItem: TTodoItem;
301 
302 begin
303   lNewToDoItem := TTodoItem.Create(aTLFile);
304   lNewToDoItem.ToDoType    := aToDoType;
305   lNewToDoItem.TokenStyle  := aTokenStyle;
306   lNewToDoItem.LineNumber  := aLineNumber;
307   lNewToDoItem.Filename    := aFileName;
308 
309   if aParts.Values[TEXT_PART_NAME] <> '' then
310     lNewToDoItem.Text:=aParts.Values[TEXT_PART_NAME];
311 
312   if aParts.Values[OWNER_PART_NAME] <> '' then
313     lNewToDoItem.Owner:=aParts.Values[OWNER_PART_NAME];
314 
315   if aParts.Values[CATEGORY_PART_NAME] <> '' then
316     lNewToDoItem.Category:=aParts.Values[CATEGORY_PART_NAME];
317 
318   if aParts.Values[PRIORITY_PART_NAME] <> '' then
319     lNewToDoItem.Priority:=StrToInt(aParts.Values[PRIORITY_PART_NAME]);
320 
321   if Assigned(aTLFile) then
322     aTLFile.Add(lNewToDoItem);
323 end;
324 
325 class procedure TToDoListCore.CreateToDoItem(aTLFile: TTLScannedFile;
326   const aFileName: string; const aStartComment, aEndComment: string;
327   const aTokenString: string; aLineNumber: Integer);
328 
329 var
330   lParsingString, lTokenToCheckFor : string;
331   lToDoTokenFound: boolean;
332   lTodoType, lFoundToDoType: TToDoType;
333   lTokenStyle, lFoundTokenStyle: TTokenStyle;
334   lParts: TStringList;
335 
336 begin
337   //DebugLn(['TfrmTodo.CreateToDoItem aFileName=',aFileName,' LineNumber=',aLineNumber]);
338   lParsingString:= TextToSingleLine(aTokenString);
339   // Remove the beginning comment chars from input string
340   Delete(lParsingString, 1, Length(aStartComment));
341   // Remove leading and trailing blanks from input
342   lParsingString := Trim(lParsingString);
343   // See if it's a TODO or DONE item
344 
345   lToDoTokenFound:=False;
346 
347   // Determine token and style
348 
349   for lTokenStyle := Low(TTokenStyle) to High(TTokenStyle) do
350     begin
351       for lTodoType := Low(TToDoType) to High (TToDoType) do
352         begin
353           lTokenToCheckFor := TODO_TOKENS[lTokenStyle, lTodoType];
354           if (LazStartsText(lTokenToCheckFor, lParsingString)) // Token match
355             and ( (Length(lParsingString)=Length(lTokenToCheckFor)) // Exact match, no further chars. Should not happen?
356                or not (lParsingString[Length(lTokenToCheckFor)+1] in ['A'..'Z','a'..'z'])
357             ) then // Extra char is not alpha
358             begin
359               lToDoTokenFound := True;
360               lFoundToDoType := lTodoType;
361               lFoundTokenStyle := lTokenStyle;
362               Break;
363             end;
364           if lToDoTokenFound then
365             break;
366         end;
367     end;
368 
369   if Not lToDoTokenFound then
370     Exit; // Not a Todo/Done item, leave
371 
372   // Remove the ending comment chars from input string
373   if (aEndComment <> '') and LazEndsStr(aEndComment, lParsingString) then
374     SetLength(lParsingString, Length(lParsingString)-Length(aEndComment));
375 
376   // Remove the Token
377   Delete(lParsingString, 1, Length(TODO_TOKENS[lFoundTokenStyle, lTodoType]));
378 
379   lParsingString := Trim(lParsingString);
380 
381   lParts:=TStringList.Create;
382   try
383     ParseToParts(lParsingString, lParts);
384     AddToDoItemFromParts(lParts, aTLFile, aFileName, aLineNumber, lFoundToDoType, lFoundTokenStyle);
385   finally
386     lParts.Free;
387   end;
388 
389 end;
390 
391 class procedure TToDoListCore.ExtractToCSV(const aListItems:TListItems;const aFilename:string);
392 var
393   lCommaList: TStringList;
394   i: Integer;
395   lToDoItem: TTodoItem;
396   s, t: String;
397 begin
398   lCommaList:=TStringList.Create;
399   try
400     lCommaList.Add(csvHeader);
401     i:=0;
402     while i<aListItems.Count do
403       begin
404         lToDoItem:=TTodoItem(aListItems[i].Data);
405         s:=LIST_INDICATORS[lToDoItem.ToDoType] + ',';
406         t:=DelChars(lToDoItem.Text,',');{Strip any commas that can cause a faulty csv file}
407         s:=s+t+','+IntToStr(lToDoItem.Priority)+','+lToDoItem.Filename+
408            ','+IntToStr(lToDoItem.LineNumber)+','+lToDoItem.Owner+','+lToDoItem.Category;
409         lCommaList.Add(s);
410         Inc(i);
411       end;
412     lCommaList.SaveToFile(aFileName);
413   finally
414     lCommaList.Clear;
415     lCommaList.Free;
416   end;
417 end;
418 
419 class procedure TToDoListCore.ScanFile(const aFileName: string;const FScannedFiles:TAvlTree);
420 var
421   ExpandedFilename: String;
422   AVLNode: TAvlTreeNode;
423   Tool: TCodeTool;
424   Code: TCodeBuffer;
425   CurFile: TTLScannedFile;
426   Src: String;
427   p: Integer;
428   NestedComment: Boolean;
429   CommentEnd: LongInt;
430   CommentStr: String;
431   CodeXYPosition: TCodeXYPosition;
432 begin
433   //DebugLn(['TfrmTodo.ScanFile ',aFileName]);
434   ExpandedFilename:=TrimFilename(aFileName);
435 
436   Code:=CodeToolBoss.LoadFile(ExpandedFilename,true,false);
437 
438   if Code=nil then begin
439     debugln(['TIDETodoWindow.ScanFile failed loading ',ExpandedFilename]);
440     exit;
441   end;
442 
443   CodeToolBoss.Explore(Code,Tool,false,false); // ignore the result
444 
445   if (Tool=nil) or (Tool.Scanner=nil) then begin
446     debugln(['TIDETodoWindow.ScanFile failed parsing ',Code.Filename]);
447     exit;
448   end;
449 
450   AVLNode:=FScannedFiles.FindKey(Pointer(Tool.MainFilename),
451                                @CompareAnsiStringWithTLScannedFile);
452   CurFile:=nil;
453   //DebugLn(['TfrmTodo.ScanFile ',Tool.MainFilename,' AVLNode=',AVLNode<>nil]);
454   if AVLNode<>nil then begin
455     CurFile:=TTLScannedFile(AVLNode.Data);
456     // Abort if this file has already been scanned and has not changed
457     if CurFile.CodeChangeStep=Tool.Scanner.ChangeStep then exit;
458   end;
459   //DebugLn(['TfrmTodo.ScanFile SCANNING ... ']);
460 
461   // Add file name to list of scanned files
462   if CurFile=nil then begin
463     CurFile:=TTLScannedFile.Create;
464     CurFile.Filename:=Tool.MainFilename;
465     FScannedFiles.Add(CurFile);
466   end;
467   // save ChangeStep
468   CurFile.CodeChangeStep:=Tool.Scanner.ChangeStep;
469   //DebugLn(['TfrmTodo.ScanFile saved ChangeStep ',CurFile.CodeChangeStep,' ',Tool.Scanner.ChangeStep]);
470   // clear old items
471   CurFile.Clear;
472   Src:=Tool.Src;
473   p:=1;
474   NestedComment:=CodeToolBoss.GetNestedCommentsFlagForFile(Code.Filename);
475 
476   repeat
477       p:=FindNextComment(Src,p);
478     if p>length(Src) then break;
479       CommentEnd:=FindCommentEnd(Src,p,NestedComment);
480     if not Tool.CleanPosToCaret(p,CodeXYPosition) then
481       begin
482         ShowMessageFmt(errScanFileFailed, [ExtractFileName(aFileName)]);
483         Exit;
484       end;
485 
486     CommentStr:=copy(Src,p,CommentEnd-p);
487     //DebugLn(['TfrmTodo.ScanFile CommentStr="',CommentStr,'"']);
488       if Src[p]='/' then
489         CreateToDoItem(CurFile,CodeXYPosition.Code.Filename, '//', '', CommentStr, CodeXYPosition.Y)
490       else if Src[p]='{' then
491         CreateToDoItem(CurFile,CodeXYPosition.Code.Filename, '{', '}', CommentStr, CodeXYPosition.Y)
492       else if Src[p]='(' then
493         CreateToDoItem(CurFile,CodeXYPosition.Code.Filename, '(*', '*)', CommentStr, CodeXYPosition.Y);
494       p:=CommentEnd;
495   until false;
496 
497 end;
498 
499 { TTLScannedFile }
500 
GetCountnull501 function TTLScannedFile.GetCount: integer;
502 begin
503   if Assigned(FItems) then
504     Result:=FItems.Count
505   else
506     Result:=0
507 end;
508 
GetItemsnull509 function TTLScannedFile.GetItems(Index: integer): TTodoItem;
510 begin
511   Result:=TTodoItem(FItems[Index]);
512 end;
513 
514 destructor TTLScannedFile.Destroy;
515 begin
516   Clear;
517   inherited Destroy;
518 end;
519 
520 procedure TTLScannedFile.Clear;
521 var
522   i: Integer;
523 begin
524   if Assigned(FItems) then
525     begin
526       for i:=0 to FItems.Count-1 do
527         TObject(FItems[i]).Free;
528       FreeAndNil(FItems);
529   end;
530 end;
531 
532 procedure TTLScannedFile.Add(aItem: TTodoItem);
533 begin
534   if not Assigned(FItems) then
535     FItems:=TFPList.Create;
536 
537   FItems.Add(aItem);
538 end;
539 
540 { TTodoItem }
541 
GetAsStringnull542 function TTodoItem.GetAsString: string;
543 begin
544   Result := TODO_TOKENS[TokenStyle, ToDoType];
545   // Priority
546   if Priority > 0 then
547     Result := Result + ' '+IntToStr(Priority);
548   // Owner
549   if Owner <> '' then
550     Result := Result + ' -o'+QuotedOwner;
551   // Category
552   if Category <> '' then
553     Result := Result + ' -c'+QuotedCategory;
554   // Text
555   Result := Result + ' : ' + Text;
556 end;
557 
QuotedStrnull558 function TTodoItem.QuotedStr(const aSrc: string; const aQuote: char): string;
559 begin
560   // Only quote if necessary
561   if (pos(aQuote, aSrc)<>0)
562     or (pos(' ', aSrc)<>0) then
563     Result := AnsiQuotedStr(aSrc, aQuote)
564   else
565     Result := aSrc;
566 end;
567 
568 constructor TTodoItem.Create(aTLFile: TTLScannedFile);
569 begin
570   FTLFile:=aTLFile;
571 end;
572 
GetQuotedOwnernull573 function TTodoItem.GetQuotedOwner: string;
574 begin
575   Result := QuotedStr(FOwner, '''');
576 end;
577 
GetQuotedCategorynull578 function TTodoItem.GetQuotedCategory: string;
579 begin
580   Result := QuotedStr(FCategory, '''');
581 end;
582 
GetAsCommentnull583 function TTodoItem.GetAsComment: string;
584 begin
585   Result := '{ '+AsString+' }';
586 end;
587 
588 end.
589