diff --git a/components/exampleswindow/exampleprojects.lpk b/components/exampleswindow/exampleprojects.lpk new file mode 100644 index 0000000000..472792f94e --- /dev/null +++ b/components/exampleswindow/exampleprojects.lpk @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/exampleswindow/exampleprojects.pas b/components/exampleswindow/exampleprojects.pas new file mode 100644 index 0000000000..ab920594ea --- /dev/null +++ b/components/exampleswindow/exampleprojects.pas @@ -0,0 +1,23 @@ +{ This file was automatically created by Lazarus. Do not edit! + This source is only used to compile and install the package. + } + +unit exampleprojects; + +{$warn 5023 off : no warning about unused units} +interface + +uses + uIntf, uConst, uLaz_Examples, uexampledata, exwinsettings, + LazarusPackageIntf; + +implementation + +procedure Register; +begin + RegisterUnit('uIntf', @uIntf.Register); +end; + +initialization + RegisterPackage('exampleprojects', @Register); +end. diff --git a/components/exampleswindow/exwinsettings.lfm b/components/exampleswindow/exwinsettings.lfm new file mode 100644 index 0000000000..0c1e9743ad --- /dev/null +++ b/components/exampleswindow/exwinsettings.lfm @@ -0,0 +1,50 @@ +object ExWinSettingsFrame: TExWinSettingsFrame + Left = 0 + Height = 240 + Top = 0 + Width = 320 + ClientHeight = 240 + ClientWidth = 320 + TabOrder = 0 + DesignLeft = 567 + DesignTop = 426 + object ButtonDefault: TButton + AnchorSideLeft.Control = DirectoryEdit1 + AnchorSideTop.Control = DirectoryEdit1 + AnchorSideTop.Side = asrBottom + Left = 20 + Height = 25 + Top = 159 + Width = 75 + BorderSpacing.Top = 10 + Caption = 'Default' + OnClick = ButtonDefaultClick + TabOrder = 0 + end + object DirectoryEdit1: TDirectoryEdit + AnchorSideLeft.Control = Owner + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + Left = 20 + Height = 29 + Top = 120 + Width = 280 + Directory = 'DirectoryEdit1' + ShowHidden = False + ButtonWidth = 23 + NumGlyphs = 1 + Anchors = [akLeft, akRight] + BorderSpacing.Left = 20 + BorderSpacing.Right = 20 + MaxLength = 0 + TabOrder = 1 + Text = 'DirectoryEdit1' + end + object Label1: TLabel + Left = 27 + Height = 21 + Top = 91 + Width = 287 + Caption = 'Directory where examples are saved' + end +end diff --git a/components/exampleswindow/exwinsettings.pas b/components/exampleswindow/exwinsettings.pas new file mode 100644 index 0000000000..01126313be --- /dev/null +++ b/components/exampleswindow/exwinsettings.pas @@ -0,0 +1,193 @@ +unit exwinsettings; +{ + ********************************************************************** + This file is part of a Lazarus Package, Examples Window. + + See the file COPYING.modifiedLGPL.txt, included in this distribution, + for details about the license. + ********************************************************************** + +This unit makes a frame that is poked into Lazarus's Options Tree. At present +all it gets back is the user's preference as to where the Example Projects +working space is. Easily extended. David Bannon, Feb 2022 + +} + + + +{$mode ObjFPC}{$H+} + +interface + +uses + Classes, SysUtils, Forms, Controls, StdCtrls, EditBtn, IDEOptionsIntf, + IDEOptEditorIntf; + +{ TExWinSettings } + +// -------- The Options Group ID, and, perhaps, a place in the Tree View ------- + +type + TExWinSettings = class(TAbstractIDEEnvironmentOptions) // needed by options group. + + private + + public + constructor Create(const pbReadRegFile: boolean); + destructor Destroy; override; + class function GetGroupCaption: String; override; + class function GetInstance: TAbstractIDEOptions; override; + procedure DoAfterWrite({%H-}Restore: boolean); override; + end; + + +// ------ This is the Frame displayed when user clicks the Tree View note ------ +type + { TExWinSettingsFrame } + TExWinSettingsFrame = class(TAbstractIDEOptionsEditor) + ButtonDefault: TButton; + DirectoryEdit1: TDirectoryEdit; + Label1: TLabel; + procedure ButtonDefaultClick(Sender: TObject); + + private + DefaultExamplesHome : string; + + public + constructor Create(TheOwner: TComponent); override; + destructor Destroy; override; + function GetTitle: String; override; + procedure ReadSettings({%H-}AOptions: TAbstractIDEOptions); override; + procedure Setup({%H-}ADialog: TAbstractOptionsEditorDialog); override; + class function SupportedOptionsClass: TAbstractIDEOptionsClass; override; + procedure WriteSettings({%H-}AOptions: TAbstractIDEOptions); override; + procedure RestoreSettings({%H-}AOptions: TAbstractIDEOptions); override; + end; + + +var + ExWindowOptionsGroup : integer; + ExWinOptionsFrameID : integer; + +implementation + +uses Dialogs, LazLogger, UConst, baseIDEIntf, LazConfigStorage, LazFileUtils, + LazIDEIntf; + +{$R *.lfm} + +{ TExWinSettings } + +constructor TExWinSettings.Create(const pbReadRegFile: boolean); +begin + // inherited Create; +end; + +destructor TExWinSettings.Destroy; +begin + inherited Destroy; +end; + +class function TExWinSettings.GetGroupCaption: String; +begin + Result := rsExampleProjects; +end; + +class function TExWinSettings.GetInstance: TAbstractIDEOptions; +begin + //result := TAbstractIDEOptions(self); // Nope, it does not like that ! + result := nil; +end; + +procedure TExWinSettings.DoAfterWrite(Restore: boolean); +begin + inherited DoAfterWrite(Restore); +end; + +{ TExWinSettingsFrame } + + +procedure TExWinSettingsFrame.ButtonDefaultClick(Sender: TObject); +begin + DirectoryEdit1.Text := DefaultExamplesHome; +end; + +constructor TExWinSettingsFrame.Create(TheOwner: TComponent); +begin + inherited Create(TheOwner); + DefaultExamplesHome := AppendPathDelim(LazarusIDE.GetPrimaryConfigPath) + + AppendPathDelim(cExamplesDir); +end; + +destructor TExWinSettingsFrame.Destroy; +begin + inherited Destroy; +end; + +function TExWinSettingsFrame.GetTitle: String; +begin + Result := rsExampleProjects; +end; + +procedure TExWinSettingsFrame.ReadSettings(AOptions: TAbstractIDEOptions); +var + Config: TConfigStorage; +begin + try + Config := GetIDEConfigStorage(cConfigFileName, true); + try + DirectoryEdit1.Text := Config.GetValue('Examples/Directory', DefaultExamplesHome); + + finally + Config.Free; + end; + except + on E: Exception do begin + DebugLn('TExWinSettingsFrame.ReadSettings Loading ' + cConfigFileName + ' failed: ' + E.Message); + end; + end; + +end; + +// Maybe the initial settings before we have a config file ? Labels and Captions. +procedure TExWinSettingsFrame.Setup(ADialog: TAbstractOptionsEditorDialog); +begin + Label1.Caption := rsDirWhereExamplesGo; +end; + +class function TExWinSettingsFrame.SupportedOptionsClass: TAbstractIDEOptionsClass; +begin + Result := nil; +end; + +// Gets called whenever user opens Options tree. +procedure TExWinSettingsFrame.WriteSettings(AOptions: TAbstractIDEOptions); +var + Config: TConfigStorage; +begin + try + Config := GetIDEConfigStorage(cConfigFileName,false); + try + Config.SetDeleteValue('Examples/Directory',DirectoryEdit1.Text, DefaultExamplesHome); + finally + Config.Free; + end; + except + on E: Exception do begin + DebugLn('TExWinSettingsFrame.ReadSettings Saving ' + cConfigFileName + ' failed: ' + E.Message); + end; + end; +end; + +procedure TExWinSettingsFrame.RestoreSettings(AOptions: TAbstractIDEOptions); +begin + inherited RestoreSettings(AOptions); +end; + + +initialization + ExWindowOptionsGroup := GetFreeIDEOptionsGroupIndex(GroupEditor); + RegisterIDEOptionsGroup(ExWindowOptionsGroup, TExWinSettings, False); // F cos I get Index from above line. I think. + + +end. diff --git a/components/exampleswindow/uconst.pas b/components/exampleswindow/uconst.pas new file mode 100644 index 0000000000..2c31ccdf0c --- /dev/null +++ b/components/exampleswindow/uconst.pas @@ -0,0 +1,66 @@ +unit uConst; +{ + ********************************************************************** + This file is part of a Lazarus Package, Examples Window. + + See the file COPYING.modifiedLGPL.txt, included in this distribution, + for details about the license. + ********************************************************************** + +This unit provides the Example Projects package with a few constants and +a number of string literals that will i18n translation. + +} + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils; + +const + cRemoteRepository = 'https://gitlab.com/api/v4/projects/32866275/repository/'; + // Immediate Local dir name under which we copy or + cExamplesDir = 'examples_work_dir'; // download examples to. Carefull about simplifying it + cConfigFileName = 'exampleprojectscfg.xml'; + +resourcestring + + // --------- Multiple units + rsExampleProjects = 'Example Projects'; + + // ---------- uLaz_Examples + rsExSearchPrompt = 'Search Here'; + rsExNoProjectFile = 'Maybe no project file ?'; + rsFoundExampleProjects = 'Found %d Example Projects'; + rsRefreshExistingExample = 'Refresh Existing Example ?'; + rsExDownloadingProject = 'Downloading Project...'; + rsExCopyingProject = 'Copying Project...'; + rsExProjectDownloadedTo = 'Project Downloaded to'; // followed by a full path name + rsExProjectCopiedTo = 'Project Copied to'; // followed by a full path name + rsExampleName = 'Name'; // Column title + rsExamplePath = 'Path'; // " + rsExampleKeyWords = 'Key Words'; // " + rsExSearchingForExamples = 'Searching for Examples'; + rsFailedToCopyFilesTo = 'Failed to copy files to'; // Followed by a dir were we, apparently, cannot write + + // These are ObjectInspector set but I believe I cannot get OI literals i18n in a Package ?? + rsExampleOpen = 'Open'; // Button Caption + rsExampleDownload = 'Download'; // " + rsExampleClose = 'Close'; // " + rsExampleCategory = 'Category'; // " + + // Settings Frame + rsDirWhereExamplesGo = 'Directory where Examples go'; + + // ------- rsExampleData + // Most literals in uExampleData are for debugging only and very unlikely to be + // seen by the end user. But a couple of network related ones may need i18n - + rsExNetWorkError = 'GitLab NetWork Error'; // Followed by system error msg + + +implementation + +end. + diff --git a/components/exampleswindow/uexampledata.pas b/components/exampleswindow/uexampledata.pas new file mode 100644 index 0000000000..5069eef132 --- /dev/null +++ b/components/exampleswindow/uexampledata.pas @@ -0,0 +1,1006 @@ +unit uexampledata; + +{ + ********************************************************************** + This file is part of a Lazarus Package, Examples Window. + + See the file COPYING.modifiedLGPL.txt, included in this distribution, + for details about the license. + ********************************************************************** + +This unit is the backend that provides an List that contains details of Lazarus +Example Projects. It might get its data from one of three different places, + +* The LazarusDir and the LazarusConfigDir. +* A locally cached master meta file Disabled as of Feb 2022 +* A remote gitlab repository (ie, if the above is not present), Disabled as of Feb 2022 + +This list can be used to populate the Lazarus Examples Window or used during the +markup of existing Lazarus Projects. The unit is used by the Lazarus Package and +a simple tool used to manage the meta data files. + +-- PATHS -- + +This only really applies in the Out of Lazarus Package usage. David Bannon, Feb 2022 + +Data is inserted into the list from different sources and might refer to +content stored in different places. + +So, wrt FFname in the list, a path starting with a slash, / or \, is an absolute +local path. OTOH, without a slash, its remote, eg, gitlab and relative to the +top of the repository. + +Special case is when we are reading the local git repository, we are doing this +to make a file to upload to the gilab repo that is an index of the remote repository, +so, no leading slash and all paths are relative to the top of the local git repo. + +This unit does not interact directly with user but it does (hopefully not often) +generate some error messages that may need i18n. Only network errors have been done. + + +} + +{$mode ObjFPC}{$H+} + +interface + +uses Classes, SysUtils, fpjson, jsonparser ; + +const MetaFileExt = '.ex-meta'; + + +type TExampleDataSource = ( FromGitlabTree, // Read all remote project meta files + FromLocalTree, // Read all local Git project meta files + FromCacheFile, // Load data from Local Cache File + FromLazSrcTree); // Searches the Lazarus Src Tree, eg ~/examples; ~/components + +type + PExRec=^TExRec; + TExRec = record + EName : string; // CamelCase version of last part of FFName + Category : string; // eg Beginner, NoDesign (read from remote data) + Keywords : TStringList; // a list of (possibly multi-word) words + FFName : string; // Path and filename of meta file. Maybe absolute or relative + Desc : string; // 1..many lines of description + end; + +type + { TExampleList } + TExampleList = class(TFPList) + private + + procedure DumpList(wherefrom: string; ShowDesc: boolean = false); + function Get(Index: integer): PExRec; + + public + constructor Create(); + destructor Destroy; override; + // Public - Puts new entry in List, Keys may be Nil + function InsertData(Cat, Desc, FFName, AName: string; Keys: TStringList): boolean; + function Find(const FFname: string): PExRec; + function AsJSON(Index: integer): string; + // Ret T if St is in Keywords at AnIndex, not necessarily equal to. + function IsInKeywords(St : string; AnIndex : integer) : boolean; + property Items[Index: integer]: PExRec read Get; default; + + +end; + +{ Note - the above list is used to generate a master.ex-meta file that might be added +the the gitlab repo. So, dir seperators MUST be /. On Windows, they will be read +from a local tree as \ and a local master.ex-meta file will need to be converted. +I think we will declare they are always /, when reading local filesystems on +Windows, must convert during the insert into list stage. } + + +type + + { TExampleData } + + TExampleData = class + private + ErrorString : String; + ExList : TExampleList; + GetListDataIndex : integer; + LazConfigDir : string; // dir (eg OPM) under which we might find more Examples + // Gets a Full URL and returns with St containing content, usually as JSON + function Downloader(URL: string; out SomeString: String): boolean; + // Does a binary safe download of a file, URL will get repositary info prepended + // and file ends up in FullDest which should be a full path and filename. + function DownLoadFile(const URL, FullDest: string): boolean; + //function EscJSON(InStr: string): string; + function ExtractArrayFromJSON(const Field: string; jItem: TJSONData; STL: TStringList): boolean; + // Passed a json block, returns the indicated field, cannot handle arrays. + // Don't rely on its base64 decoding a binary file, see DownLoadFile() instead. + function ExtractFromJSON(const Field, data: string; Base64: boolean=false) : string; + function ExtractFromJSON(const Field: string; const jItem: TJSONData; out + Res: string; Base64: boolean = false): boolean; + function GetLazDir: string; + + // The returned date string down to seconds includes time zone in ISO8601 + // eg 2022-01-09T11:56:51+11:00 + function GetLocalTime: ANSIstring; + + // Receives a pretested JSON (not just a field) containing metadata of an Example + // Returns false if data missing, drops msg to console about bad field. + // Path may be relative or absolute (ie starting with '/' or '\'). Ones without + // a leading slash are remote, ie gitlab. Ones with a slash should be resolable + // locally. Note when indexing a local git tree, relative must be used, ie top of + // git tree. In this mode of course, the entry will not be resolvable locally. + function InsertJSONData(jItem: TJSONData; FFName: string; AName: string = '' ): boolean; + function LoadCacheFile(FFName: string): boolean; + function ReadMasterJSON(FileContent: TStringList): boolean; + function ReadRemoteMetaFile(URL: string): boolean; // download and read meta file + // Gets passed a block of json, wrapped in {} containing several fields relating + // one example project. Path is ready to use in the List. Not suited to json + // With an internal Path field (ie master.ex-meta) + function ReadSingleJSON(FileContent: TStringList; PathToStore: string = ''): boolean; + function ScanLocalTree(Path: string; PathAbs: boolean): boolean; + // Will either scan and add what it finds to the List (if STL is nil) or it + // will add each full URL to the StringList if its valid and created. + function ScanRemoteTree(Path: string; STL: TstringList = nil): boolean; + function ScanOneTree(Path: string; out St: string): boolean; + procedure fSetErrorString(Er : string); + + function WriteMasterMeta(FFileName: string): boolean; + + public + RemoteRepo : string; // eg https://gitlab.com/api/v4/projects/32480729/repository/ + + ExamplesHome : string; // dir where we will save a working copy of examples too, usually LazConfigDir + LazSrcDir : string; // Laz dir where, eg ~/examples lives + GitDir : string; // where we look for a local git repo containg examples + KeyFilter : string; // A list of words, possibly grouped by " to filter Keywords + CatFilter : string; // A string that may contain 0 to n words, each word being a category as filtered by GetListData() + // A service function, tests passed St to ensure its + // a valid lump of Example Meta Data. + function TestJSON(const J: string; out Error, Cat: string): boolean; + // Public, returns with next set of data, false if no more available. + // Filters using CatFilter if CatFilter is not empty. + // If passed KeyList is not nil, filters keywords against KeyList. + function GetListData(out Proj, Cat, Path, Keys: string; GetFirst: boolean; + KeyList: TStringList = nil): boolean; + // Passed a created TStrings that it clears and fills in with all know categories + function getCategoryData(const CatList : TStrings) : boolean; + function GetDesc(const FFname: string): string; + constructor Create; + procedure LoadExData(DataSource: TExampleDataSource); + destructor Destroy; override; + procedure DumpExData(); + // A service method, called by the GUI to download a project/ + // Pass it a full example remote dir (eg Beginner/Laz_Hello/). + function DownLoadDir(const FExampDir: string): boolean; + function Count : integer; + function MasterMeta(DirOnly: boolean = false): string; // returns the full Master Metafile name + function ExtractFieldsFromJSON(const JStr: string; out EName, Cat, Keys, Desc, + Error: string): boolean; + property ErrorMsg : string read ErrorString write FSetErrorString; + class function EscJSON(InStr: string): string; + end; + + +implementation + + +uses LCLProc, + uConst, + httpprotocol, // for http encoding + fphttpclient, // determines a dependency on FPC 3.2.0 or later. Must for https downloads + opensslsockets, + ssockets, fpopenssl, + lazfileutils, fileutil, + jsonscanner, // these are the FPC JSON tools + base64 + , laz2_DOM, laz2_XMLRead // just to get LazarusDirectory, remove if we find a better way ! + {$ifdef LINUX},Unix {$endif} // We call a ReReadLocalTime(); + {, IDEOptionsIntf}, LazIDEIntf; + +const + LastUpDate = 'LastUpDate'; // Name of JSON item were we store last update + +{ A URL starts with eg 'https://gitlab.com/api/v4/projects/32480729/repository/' +It contains a multidigit number that identifies the gitlab project. The number is a +combination of Owner (account, group..) and repository name. Its identified in Gitlab +web pages as "Project ID", group id will not work. A full URL might look like this - +https://gitlab.com/api/v4/projects/32866275/repository/files/Utility%2FExScanner%2Fproject1.ico?ref=main +} + + +// ============================================================================= +// T E X A M P L E L I S T +//============================================================================== + +function TExampleList.Get(Index: integer): PExRec; +begin + Result := PExRec(inherited get(Index)); +end; + +function TExampleList.InsertData(Cat, Desc, FFName, AName : string; Keys: TStringList): boolean; +var + ExRecP : PExRec; +begin + ExRecP := find(FFName); + new(ExRecP); + ExRecP^.Category := Cat; + ExRecP^.KeyWords := Keys; // Nil is acceptable + ExRecP^.Desc := Desc; + ExRecP^.FFName := FFName; + ExRecP^.EName := AName; + result := (inherited Add(ExRecP) > -1); +end; + + // Returns an unquoted string being one JSON Escaped record from list. +function TExampleList.AsJSON(Index : integer) : string; // Not used, maybe remove ? Or Add in EName +begin + Result := ''; + Result := Result + 'Category : ' + Items[Index]^.Category + #10; + Result := Result + 'Keywords : ' + Items[Index]^.Keywords.Text + #10#10; + Result := Result + Items[Index]^.Desc; + Result := Result.Replace('\', '\\', [rfReplaceAll] ); + Result := Result.Replace('"', '\"', [rfReplaceAll] ); +end; + +function TExampleList.IsInKeywords(St: string; AnIndex: integer): boolean; + var KeyWord : String; +begin + result := false; + if pos(lowercase(St), lowercase(Items[AnIndex]^.EName)) > 0 then exit(true); + for KeyWord in Items[AnIndex]^.Keywords do begin + if pos(lowercase(St), lowercase(Keyword)) > 0 then exit(True); + end; +end; + + +procedure TExampleList.DumpList(wherefrom: string; ShowDesc : boolean = false); // ToDo : remove this, its just a debug method +var + i : integer = 0; +begin + DebugLn('-------- ExampleData Examples List ' + Wherefrom + '----------'); + while i < count do begin + DebugLn('<<<< List - FFName=[' + Items[i]^.FFName +'] Cat=[' + Items[i]^.Category + + '] EName=' + Items[i]^.EName + + '] Key=[' + Items[i]^.Keywords.Text + ']'); + if ShowDesc then + DebugLn(Items[i]^.Desc); + inc(i); + end; +end; + +constructor TExampleList.Create(); +begin + inherited Create; +end; + +destructor TExampleList.Destroy; +var + i : integer; +begin + for I := 0 to Count-1 do begin + if Items[i]^.Keywords <> nil then + Items[i]^.Keywords.free; + dispose(Items[i]); + end; + inherited Destroy; +end; + +function TExampleList.Find(const FFname: string): PExRec; +var + i : integer = 0; +begin + while i < count do begin + if Items[i]^.FFname = FFname then + exit(Items[i]); + inc(i); + end; + Result := nil; +end; + +// ============================================================================= +// T E X A M P L E D A T A +// ============================================================================= + + +function TExampleData.Count: integer; +begin + result := ExList.Count; +end; + +procedure TExampleData.fSetErrorString(Er : string); +begin + ErrorString := Er; + Debugln(ErrorString); +end; + +// Rets a path to where we will putting our downloaded or copied ex projects. +// At present, this is the /downloaded_examples/ +// if not true, returns the FFName of the master meta file, same place. +function TExampleData.MasterMeta(DirOnly : boolean = false) : string; +begin + //result := LazConfigDir + cExamplesDir + pathdelim; + result := AppendPathDelim(ExamplesHome); + if not DirOnly then + result := Result + 'master' + MetaFileExt; +end; + +function TExampleData.ExtractFieldsFromJSON(const JStr: string; out EName, Cat, + Keys, Desc, Error: string): boolean; +var + jData, jItem : TJSONData; + STL : TStringList; + St : string; +begin + Error := ''; + result := TestJSON(JStr, Error, Cat); + if Not Result then exit(False); // some basic tests done, so + jData := GetJSON(JStr); // we know these 2 lines are safe. + jItem := jData.Items[0]; + STL := TStringList.Create; + Result := False; + try + if not ExtractFromJSON('Description', jItem, Desc) then begin + Desc := ''; + end; + Keys := ''; + if ExtractArrayFromJSON('Keywords', JItem, StL) then begin + for St in STL do + Keys := Keys + '"' + ST + '",'; + if Keys.length > 1 then + delete(Keys, Keys.Length, 1); + end; + EName := TJSONObject(jData).Names[0]; + Result := True; + finally + STL.Free; + JData.Free; + end; +end; + +function TExampleData.TestJSON(const J : string; out Error, Cat : string) : boolean; +var + jData, jItem : TJSONData; +begin + Result := true; + if (J.Length = 0) or (J[1] <> '{') then begin // Ignore obvious non JSON + Error := 'Empty text or does not start with {'; + exit(False) + end; + try + try + jData := GetJSON(J); // Is it valid JSON ? + jItem := jData.Items[0]; + except + on E: EJSONParser do begin + Error := 'ERROR Parsing- invalid JSON ' + E.Message; + jData := Nil; // Appears nothing is allocated on error ? + exit(false); + end; + on E: EScannerError do begin + Error := 'ERROR Scanning- invalid JSON ' + E.Message; + jData := Nil; // Appears nothing is allocated on error ? + exit(false); + end; + end; + + if TJSONObject(jItem).Count = 0 then begin + Error := 'WARNING - file does not contain suitable JSON : '; + exit(false); + end; + if not ExtractFromJSON('Category', jItem, Cat) then begin + Error := 'WARNING - Category Not Set '; + exit(false); + end; + finally + jData.free; + end; +end; + +// jItem never contains Project Path, its either found in json Name (master) +// or derived from where we found the project (individual). So, always passed here. +function TExampleData.InsertJSONData(jItem : TJSONData; FFName : string; AName : string = ''): boolean; +var + Cat, Desc, AnotherName : String; + // index : integer; + KeyWords : TStringList; +begin + ExtractFromJSON('Category', jItem, Cat); // An empty Cat is acceptable but undesirable. + if not ExtractFromJSON('Description', jItem, Desc) then exit(False); + KeyWords := TStringList.Create; + ExtractArrayFromJSON('Keywords', jItem, Keywords); + if AName <> '' then + AnotherName := AName + else + if not ExtractFromJSON('Name', jItem, AnotherName) then + AnotherName := ''; + Result := ExList.InsertData(Cat, Desc, FFName, AnotherName, KeyWords); + if not Result then KeyWords.Free; // false means its not gone into list so our responsibility go free +end; + +// Scans local tree below 'Path' looking for any likely Example Metadata files. +// For each, it loads content into a StringList and passes it to an Insert method. +// If AddPath, the full path is inserted, not just the relative one, eg extra dirs +function TExampleData.ScanLocalTree(Path : string; PathAbs : boolean) : boolean; +var + STL : TStringList = nil; + FileContent : TStringList; + St : string; +begin + STL := FindAllFiles(Path, '*' + MetaFileExt, True); + try + for St in STL do begin + //debugln('TExampleData.ScanLocalTree 1 Looking at ' + St); + if pos('master' + MetaFileExt, St) > 0 then continue; // don't do master if you stumble across one + if pos(cExamplesDir, St) > 0 then continue; // thats our downloaded location + FileContent := TStringList.Create; + FileContent.LoadFromFile(St); // Thats contents of one individual metadata file + try + if PathAbs then + Result := ReadSingleJSON(FileContent, St) + else Result := ReadSingleJSON(FileContent, copy(St, Path.Length+1, 1000)); + if not Result then begin + debugln('Offending file is ' + St); + debugln(ErrorMsg); + exit(False); + end; + finally + FileContent.Free; + end; + end; + finally + STL.Free; + end; +end; + + +function TExampleData.ReadSingleJSON(FileContent : TStringList; PathToStore : string = '') : boolean; +var + jData, jItem : TJSONData; +begin + Result := true; + if (FileContent.Count > 0) and (FileContent[0][1] = '{') then begin // Ignore obvious non JSON + try + try + jData := GetJSON(FileContent.Text); // Is it valid JSON ? + jItem := jData.Items[0]; + except + on E: EJSONParser do begin + ErrorMsg := 'ERROR EJSONParser- invalid JSON ' + E.Message; + jData := Nil; // Appears nothing is allocated if error ? + exit(false); + end; + on E: EScannerError do begin // Thats in jsonscanner unit, Must doc on Wiki !!! + ErrorMsg := 'ERROR EScanner- invalid JSON ' + E.Message; // this is typically a single \ + jData := Nil; // Appears nothing is allocated if error ? + exit(false); + end; + end; + if TJSONObject(jItem).Count = 0 then begin + debugln('WARNING - file does not contain suitable JSON : '); + exit(false); + end; + InsertJSONData(jItem, PathToStore, TJSONObject(jData).Names[0]); + finally + jData.free; + end; + end; +end; + +destructor TExampleData.Destroy; +begin + ExList.free; + inherited Destroy; +end; + +procedure TExampleData.DumpExData; // ToDo : remove this, just a debug thingo +begin + ExList.DumpList('TExampleData.Dump', True); +end; + +constructor TExampleData.Create(); +begin + ExList := TExampleList.Create; + LazConfigDir := appendPathDelim(LazarusIDE.GetPrimaryConfigPath); +end; + +procedure TExampleData.LoadExData(DataSource: TExampleDataSource); +begin + // If we are loading the data from either the remote gitlab tree or a local + // git tree, we save the master file. + if not DirectoryExists(MasterMeta(True)) then + if not ForceDirectory(MasterMeta(True)) then exit; + case DataSource of + FromGitLabTree : begin // too slow to be useful + ScanRemoteTree(''); + WriteMasterMeta('master' + MetaFileExt); // save in working dir + end; + FromLocalTree : begin // not used in Lazarus Package + if ScanLocalTree(GitDir, False) then // This should leave relative paths, suitable to upload to gitlab + WriteMasterMeta(GitDir + 'master' + MetaFileExt); // save in git tree ready to upload. + end; + FromLazSrcTree : begin + ScanLocalTree(GetLazDir(), True); // Scan the Lazarus SRC tree + ScanLocalTree(LazConfigDir, True); // Get, eg, any OPM Examples + end; + FromCacheFile : begin + if not LoadCacheFile(MasterMeta()) then begin + DownLoadFile('master' + MetaFileExt, MasterMeta()); + LoadCacheFile(MasterMeta()); // ToDo : Test that worked + end; + ScanLocalTree(LazConfigDir, True); // Get, eg, any OPM Examples + end; + end; +// if ExList.Count = 0 then begin + debugln('TExampleData.LoadExData - found examples = ' + inttostr(ExList.Count)); + debugln('Lazarus Dir (ie source tree) = ' + GetLazDir()); + debugln('Lazarus Config Dir = ' + LazConfigDir); + debugln('Examples Home Dir = ' + ExamplesHome); +// end; +end; + + +// ****************** Local master meta File methods *************************** + +function TExampleData.ReadMasterJSON(FileContent : TStringList) : boolean; +var + jData, jItem : TJSONData; + i : integer; +begin + Result := true; + + if (FileContent.Count > 0) and (FileContent[0][1] = '{') then begin // Ignore obvious non JSON + try + try + jData := GetJSON(FileContent.Text); // Is it valid JSON ? + except + on E: EJSONParser do begin + ErrorMsg := 'ERROR EJSONParser - invalid JSON ' + E.Message; + jData := Nil; // Appears nothing is allocated if error ? + exit(false); + end; + on E: EScannerError do begin + ErrorMsg := 'ERROR EScannerError - invalid JSON ' + E.Message; + jData := Nil; // Appears nothing is allocated if error ? + exit(false); + end; + end; + for i := 0 to jData.Count-1 do begin // check its real JSON, not just a field. + jItem := jData.Items[i]; // do not free. + if TJSONObject(jItem).Count > 0 then begin // might be ... + InsertJSONData(jItem, TJSONObject(jData).Names[i]); + end; + end; + finally + freeandnil(jData); + end; + end else result := False; +end; + +function TExampleData.LoadCacheFile(FFName : string) : boolean; +var + FileContent : TStringList; +begin + if not FileExists(FFName) then exit(False); + FileContent := TStringList.Create; + try + FileContent.LoadFromFile(FFname); + Result := ReadMasterJSON(FileContent); + if not Result then + debugln('Offending file is ' + FFName); + finally + FileContent.Free; + end; + Result := true; +end; + +{ environmentoptions.xml + + + + ... + .... } + + +function TExampleData.GetLazDir() : string; // Todo : make direct call +var + Doc : TXMLDocument; + Node, Node1 : TDOMNode; +begin + Result := ''; + ReadXMLFile(Doc, LazConfigDir + 'environmentoptions.xml'); + Node1 := Doc.DocumentElement.FindNode('EnvironmentOptions'); + if Node1 <> nil then begin + Node := Node1.FindNode('LazarusDirectory'); + if Node <> nil then + Result := AppendPathDelim(Node.Attributes.GetNamedItem('Value').NodeValue); + end; + Doc.free; +end; + +function TExampleData.GetLocalTime: ANSIstring; +var + ThisMoment : TDateTime; + Res : ANSIString; + Off : longint; +begin + {$ifdef LINUX} + ReReadLocalTime(); // in case we are near daylight saving time changeover + {$endif} + ThisMoment:=Now; + Result := FormatDateTime('YYYY-MM-DD',ThisMoment) + 'T' + + FormatDateTime('hh:mm:ss',ThisMoment); + Off := GetLocalTimeOffset(); + if (Off div -60) >= 0 then Res := '+' + else Res := '-'; + if abs(Off div -60) < 10 then Res := Res + '0'; + Res := Res + inttostr(abs(Off div -60)) + ':'; + if (Off mod 60) = 0 then + Res := res + '00' + else Res := Res + inttostr(abs(Off mod 60)); + Result := Result + res; +end; + +class function TExampleData.EscJSON(InStr : string) : string; +begin + Result := InStr.Replace('\', '\\', [rfReplaceAll]); + Result := Result.Replace('"', '\"', [rfReplaceAll]); + Result := Result.Replace(#10, '\n', [rfReplaceAll] ); // LF + Result := Result.Replace(#13, '', [rfReplaceAll] ); // CR + Result := Result.Replace(#09, '', [rfReplaceAll] ); // tab +end; + +function TExampleData.WriteMasterMeta(FFileName : string) : boolean; +var + i : integer; + STL : TStringList; + St, StIndexed : string; +begin + STL := TStringList.Create; + StL.Add('{'#10'"' + LastUpDate + '":"' + GetLocalTime() +'",'); + + for i := 0 to ExList.Count-1 do begin + StL.Add('"' + EscJSON(ExList.Items[i]^.FFname) + '" : {'); // Must be unique + StL.Add(' "Name" : "' + EscJSON(ExList.Items[i]^.EName) + '",'); + StL.Add(' "Category" : "' + EscJSON(ExList.Items[i]^.Category) + '",'); + St := ''; + for StIndexed in ExList.Items[i]^.Keywords do + St := St + '"' + StIndexed + '",'; + if St.Length > 0 then delete(St, St.Length, 1); // Remove trailing comma + StL.Add(' "Keywords" : [' + St + '],'); + StL.Add(' "Description" : "' + EscJSON(ExList.Items[i]^.Desc) + '"},'); + end; + if STL.Count > 1 then begin + St := STL[STL.Count-1]; + delete(St, St.Length, 1); + STL[STL.Count-1] := St; + end; + Stl.Add('}'); + deletefile(FFileName); // ToDo : test its there first and then test delete worked + STL.SaveToFile(FFileName); + STL.Free; + Result := fileexists(FFileName); +end; + + +// ******************** Methods relating to using the data ******************* + +function TExampleData.GetListData(out Proj, Cat, Path, Keys : string; + GetFirst: boolean; KeyList : TStringList = nil): boolean; +var + St : string; + DoContinue : boolean = false; +begin + //KeyList may be Nil, if so, ignore + Result := True; + if CatFilter = '' then exit(False); + if GetFirst then + GetListDataIndex := 0; + while True do begin + if GetListDataIndex >= ExList.Count then exit(False); + if CatFilter <> '' then begin // Find an entry in one of the categories + // orig a while instead of if, needed to use DoContinue ... Why ? + if pos(ExList.Items[GetListDataIndex]^.Category, CatFilter) < 1 then begin + inc(GetListDataIndex); + continue; + end; + end; + if KeyList <> Nil then begin + for St in KeyList do + // IndexOf requires a 1:1 match, we want to know if St is in the keyword. + //if ExList.Items[GetListDataIndex]^.Keywords.IndexOf(St) = -1 then begin + if not ExList.IsInKeywords(St, GetListDataIndex) then begin + inc(GetListDataIndex); + DoContinue := True; + Break; + end; + if DoContinue then begin // Hmm, a GoTo would be easier ...... + DoContinue := False; + Continue; + end; + end; + break; + end; + Proj := ExList.Items[GetListDataIndex]^.EName; + Cat := ExList.Items[GetListDataIndex]^.Category; + Path := ExtractFilePath(ExList.Items[GetListDataIndex]^.FFname); + Keys := ''; + for St in ExList.Items[GetListDataIndex]^.Keywords do + Keys := Keys + St + ' '; + inc(GetListDataIndex); +end; + +function TExampleData.getCategoryData(const CatList: TStrings): boolean; +var + P : PExRec; +begin + if CatList = nil then exit(false); + CatList.Clear; + for P in ExList do begin + if CatList.Indexof(P^.Category) < 0 then + CatList.Add(P^.Category); + end; + Result := True; +end; + +// Passed the FFName, a combination of Path and Proj including '.ex-meta'. +function TExampleData.GetDesc(const FFname: string): string; +var + P : PExRec; +begin + for P in ExList do begin + if (lowercase(P^.FFname) = lowercase(FFname)+MetaFileExt) then begin // extension must remain lower case + exit(P^.Desc); + end; + end; + Result := ''; + debugln('TExampleData.GetDesc - did not find Desc for ' + FFname); + debugln('Spelling of Name must match directory name (case insensitive)'); + ExList.DumpList('TExampleData.GetDesc', True); +end; + + +// ************* Methods relating to getting REMOTE data ******************* + +function TExampleData.DownLoadDir(const FExampDir : string): boolean; +var + St : string; + STL : TStringlist; +begin + STL := TStringList.Create; + try + result := ScanRemoteTree(FExampDir, STL); + for St in STL do begin + if not DirectoryExistsUTF8(MasterMeta(True) + ExtractFileDir(St)) then + ForceDirectory(MasterMeta(True) + ExtractFileDir(St)); // ToDo : but that might fail + DownLoadFile(St, MasterMeta(True) + St); + end; + finally + STL.Free; + end; +end; + + +function TExampleData.DownLoadFile(const URL, FullDest : string) : boolean; +var + St, S : string; + MemBuffer : TMemoryStream; + DecodedStream : TMemoryStream; + Decoder : TBase64DecodingStream; +begin + if not Downloader(RemoteRepo + 'files/' + HTTPEncode(URL) + '?ref=main', St) then begin + ErrorMsg := 'TExampleData.ReadMetaFile - download FAILED ' + URL; + exit(false); + end; + S := ExtractFromJSON('content', St, False); // Bring it back still base64 encoded + MemBuffer := TMemoryStream.Create; // Speedups possible here. BuffStream ? + try + MemBuffer.Write(S[1], S.length); + membuffer.Position := 0; + DecodedStream := TMemoryStream.Create; + Decoder := TBase64DecodingStream.Create(MemBuffer); + try + DecodedStream.CopyFrom(Decoder, Decoder.Size); + DecodedStream.SaveToFile(FullDest); // Does not appear to benifit from TBufferedFileStream + except on E: EStreamError do + ErrorMsg := 'TExampleData.DownLoadFile - Error decoding ' + URL + ' ' + E.Message; + end; + finally + MemBuffer.Free; + DecodedStream.Free; + Decoder.Free; + end; + result := fileexists(FullDest); +end; + + +// Passed some json, returns the indicated field IFF its an arrays. The TStringList +// must have been created before being passed. +function TExampleData.ExtractArrayFromJSON(const Field : string; jItem : TJSONData; STL : TStringList) : boolean; +// ToDo : better to handle this with a set or array ? Once populated, it does not change +var + JObject : TJSONObject; + jArray : TJSONArray; + i : integer; +begin + result := true; + try + JObject := TJSONObject(jItem); // does not require a free + if jObject.Find(Field, JArray) then + for i := 0 to JArray.count -1 do + STL.Add(JArray.Items[i].asstring); + except + on E:Exception do begin + Result := False; // Invalid JSON or content not present + ErrorMsg := 'Exception while decoding JSON looking for ' + Field; + end; + end; +end; + +function TExampleData.ExtractFromJSON(const Field, data : string; Base64 : boolean=false) : string; +var + JData : TJSONData; + JObject : TJSONObject; + jStr : TJSONString; +begin + result := ''; + try + try + JData := GetJSON(Data); // requires a free + JObject := TJSONObject(jData); // does not require a free + if jObject.Find(Field, Jstr) then begin + if Base64 then + Result := DecodeStringBase64(jStr.AsString) + else Result := jStr.AsString; + end else ErrorMsg := 'Response has no ' + Field + ' field'; + except + on E:Exception do begin + Result := ''; // Invalid JSON or content not present + ErrorMsg := 'Exception while decoding JSON looking for ' + Field; + end; + end; + finally + JData.Free; + end; + if Result = '' then debugln('ERROR, we did not find content in ' + Field); +end; + +// Returns false if cannot parse passed jItem, thats not necessarily an error, +// Path will not be here if reading individual metadata files. +// If it is an error, ErrorString is set. +function TExampleData.ExtractFromJSON(const Field : string; const jItem : TJSONData; + out Res : string; Base64 : boolean=false) : boolean; +var + JObject : TJSONObject; + jStr : TJSONString; +begin + res := ''; + try + JObject := TJSONObject(jItem); // does not require a free + if jObject.Find(Field, Jstr) then begin + if Base64 then + Res := DecodeStringBase64(jStr.AsString) + else Res := jStr.AsString; + end else if Field <> 'Path' then begin + ErrorMsg := 'Response has no ' + Field + ' field'; + end; + except + on E:Exception do // Invalid JSON or content not present + ErrorMsg := 'Exception while decoding JSON looking for ' + Field; + end; + Result := (Res <> ''); +end; + + +// Gets passed the RHS of URL of a metadata file, adds that content to list. +// eg Beginner/Laz_Hello/Laz_Hello.ex-meta +function TExampleData.ReadRemoteMetaFile(URL : string): boolean; +var + St : string; + StL : TStringList; +begin + if not Downloader(RemoteRepo + 'files/' + HTTPEncode(URL) + '?ref=main', St) then begin + ErrorMsg := 'TExampleData.ReadMetaFile - download FAILED'; + exit(false); + end; + StL := TStringList.Create; + try + STL.Text := ExtractFromJSON('content', St, True); // get 'content' and decode base64 + result := ReadSingleJSON(STL, URL); + if not Result then + debugln('Offending remote file is ' + URL); + finally + STL.Free; + end; +end; + +// https://gitlab.com/api/v4/projects/32866275/repository/files/Utility/ExScanner/project1.ico?ref=main +// curl "https://gitlab.com/api/v4/projects/32866275/repository/files/Utility%2FExScanner%2Fproject1.ico?ref=main" + +function TExampleData.ScanRemoteTree(Path : string; STL : TstringList = nil) : boolean; +// warning - recursive function. +var + St : string; + jData : TJSONData; + jObject : TJSONObject; + jArray : TJSONArray; + i : integer; +begin + ScanOneTree(Path, St); + jData := GetJSON(St); + jArray:=TJSONArray(jData); + for i:=0 to jArray.Count-1 do begin + jObject:= TJSONObject(jArray[i]); + if jObject.Find('type').AsString = 'tree' then // tree and blob are gitlab defines, in the download + ScanRemoteTree(jObject.Find('path').AsString, STL); + if (jObject.Find('type').AsString = 'blob') then begin // A blob is a usable file + if STL <> nil then + STL.add(jObject.Find('path').AsString) + else // OK, fill in List mode. + if (pos(MetaFileExt, jObject.Find('path').AsString) > 0) then begin + if pos('master' + MetaFileExt, jObject.Find('path').AsString) < 1 then // don't do master meta file + if STL = Nil then + ReadRemoteMetaFile(jObject.Find('path').AsString ); + end; + end; + end; + jArray.Free; + Result := true; +end; + +function TExampleData.ScanOneTree(Path : string; out St : string) : boolean; // needed +var + URL : string; +begin + if Path <> '' then + URL := RemoteRepo + 'tree?path=' + Path + else URL := RemoteRepo + 'tree'; + Result := Downloader(URL, St); +end; + + +function TExampleData.Downloader(URL: string; out SomeString: String): boolean; +var + Client: TFPHTTPClient; +begin + // This is a dumb downloader, if you need auth then maybe look at transgithub in tomboy-ng + // Further, gitlab API seems quite slow, up to a second for an 80K icon file ?? + // curl "https://gitlab.com/api/v4/projects/32866275/repository/files/Utility%2FExScanner%2Fproject1.ico?ref=main" + // curl does the same thing in a bit over half that time. Hmm.... + Client := TFPHttpClient.Create(nil); + Client.AddHeader('User-Agent','Mozilla/5.0 (compatible; fpweb)'); + Client.AddHeader('Content-Type','application/json; charset=UTF-8'); + Client.AllowRedirect := true; + SomeString := ''; + try + try + SomeString := Client.Get(URL); + except + on E: ESocketError do begin + ErrorMsg := rsExNetWorkError + ' ' + E.Message; + exit(false); + end; + on E: EInOutError do begin + ErrorMsg := rsExNetWorkError + ' InOut ' + E.Message; + exit(False); + end; + on E: ESSL do begin + ErrorMsg := rsExNetWorkError + ' SSL ' + E.Message; + exit(False); + end; + on E: Exception do begin // Following don't need i18n, we check they are there ! + case Client.ResponseStatusCode of + 401 : ErrorMsg := 'GitHub.Downloader Exception ' + E.Message + + ' downloading ' + URL + + ' 401 Maybe your Token has expired or password is invalid ??'; + 404 : ErrorMsg := 'GitHub.Downloader Exception ' + E.Message + + ' downloading ' + URL + ' 404 File not found ' + URL; + end; + exit(false); + end; + end; + finally + Client.Free; + end; + result := true; +end; + +end. + diff --git a/components/exampleswindow/uintf.pas b/components/exampleswindow/uintf.pas new file mode 100644 index 0000000000..7e7d830ae6 --- /dev/null +++ b/components/exampleswindow/uintf.pas @@ -0,0 +1,106 @@ +unit uIntf; + +{ + ********************************************************************** + This file is part of a Lazarus Package, Examples Window. + + See the file COPYING.modifiedLGPL.txt, included in this distribution, + for details about the license. + ********************************************************************** + +This unit provides the interface between Lazarus and the Package. + +} + +{$mode objfpc}{$H+} + +interface + +uses + Classes, + //LCL, + LCLType, + //IDEIntf, + MenuIntf, IDECommands, ToolBarIntf, IDEOptEditorIntf; + +procedure Register; + +implementation + +uses uLaz_Examples, uConst, lazlogger, + LazIDEintf, LazFileUtils, BuildIntf, ExWinSettings, + baseIDEIntf, IDEOptionsIntf, LazConfigStorage, SysUtils; + +// Note : IDEEnvironmentOptions.GetParsedLazarusDirectory is the Lazarus STC tree. + +function GetExamplesHomeDir() : string; +var + Config: TConfigStorage; +begin + try + Config := GetIDEConfigStorage(cConfigFileName, true); + try + Result := Config.GetValue('Examples/Directory', + AppendPathDelim(LazarusIDE.GetPrimaryConfigPath) + + AppendPathDelim(cExamplesDir)); + + finally + Config.Free; + end; + except + on E: Exception do begin + DebugLn('Examples UIntf GetExamplesDirectory Loading ' + cConfigFileName + ' failed: ' + E.Message); + Result := IDEEnvironmentOptions.GetParsedLazarusDirectory; + end; + end; + +end; + +procedure IDEMenuSectionClicked(Sender: TObject); +var + ProjectFFile : string; +begin + FormLazExam := TFormLazExam.Create(nil); + try + FormLazExam.ExamplesHome := GetExamplesHomeDir(); + FormLazExam.RemoteRepo := cRemoteRepository; + FormLazExam.ShowModal; + ProjectFFile := FormLazExam.ProjectToOpen; + finally + FormLazExam.Free; + FormLazExam := nil; + end; + if ProjectFFile <> '' then + LazarusIDE.DoOpenProjectFile(ProjectFFile, [ofProjectLoading]); +end; + +procedure Register; +var + IDEShortCutX: TIDEShortCut; + IDECommandCategory: TIDECommandCategory; + IDECommand: TIDECommand; +begin + IDEShortCutX := IDEShortCut(VK_E, [ssCtrl, ssAlt], VK_UNKNOWN, []); + IDECommandCategory := IDECommandList.FindCategoryByName('ToolMenu'); + IDECommand := nil; + if IDECommandCategory <> nil then + begin + IDECommand := RegisterIDECommand(IDECommandCategory, rsExampleProjects, rsExampleProjects, IDEShortCutX, nil, @IDEMenuSectionClicked); + if IDECommand <> nil then + RegisterIDEButtonCommand(IDECommand); + end; + RegisterIDEMenuCommand(itmSecondaryTools, rsExampleProjects, rsExampleProjects + ' ...', nil, @IDEMenuSectionClicked, IDECommand, 'pkg_oep'); + RegisterIDEMenuCommand(ComponentPalettePageDropDownExtraEntries, rsExampleProjects, rsExampleProjects + ' ...', nil, @IDEMenuSectionClicked, nil, 'pkg_oep'); + + ExWinOptionsFrameID := RegisterIDEOptionsEditor(ExWindowOptionsGroup, TExWinSettingsFrame, 9999)^.Index; // AIndex = what ??? + +end; + +initialization + + +finalization + + +end. + diff --git a/components/exampleswindow/ulaz_examples.lfm b/components/exampleswindow/ulaz_examples.lfm new file mode 100644 index 0000000000..4b8cb9af7a --- /dev/null +++ b/components/exampleswindow/ulaz_examples.lfm @@ -0,0 +1,194 @@ +object FormLazExam: TFormLazExam + Left = 562 + Height = 574 + Top = 168 + Width = 781 + Caption = 'Prototype Lazarus Examples Window' + ClientHeight = 574 + ClientWidth = 781 + OnCreate = FormCreate + OnDestroy = FormDestroy + OnShow = FormShow + LCLVersion = '2.3.0.0' + object Memo1: TMemo + AnchorSideLeft.Control = Owner + AnchorSideTop.Control = Splitter2 + AnchorSideTop.Side = asrBottom + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + AnchorSideBottom.Control = CheckGroupCategory + Left = 5 + Height = 216 + Top = 225 + Width = 771 + Anchors = [akTop, akLeft, akRight, akBottom] + BorderSpacing.Left = 5 + BorderSpacing.Right = 5 + BorderSpacing.Bottom = 5 + Lines.Strings = ( + 'Memo1' + ) + ParentShowHint = False + ReadOnly = True + ScrollBars = ssAutoVertical + TabOrder = 2 + end + object ListView1: TListView + AnchorSideLeft.Control = Owner + AnchorSideTop.Control = Panel1 + AnchorSideTop.Side = asrBottom + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + AnchorSideBottom.Control = Splitter2 + Left = 5 + Height = 178 + Hint = 'Click for Info, Double Click to download' + Top = 37 + Width = 776 + Anchors = [akTop, akLeft, akRight, akBottom] + BorderSpacing.Left = 5 + BorderSpacing.Top = 5 + BorderSpacing.Bottom = 5 + Columns = < + item + end + item + end + item + Width = 661 + end> + ParentShowHint = False + ReadOnly = True + ShowHint = True + TabOrder = 1 + OnClick = ListView1Click + OnDblClick = ListView1DblClick + OnSelectItem = ListView1SelectItem + end + object CheckGroupCategory: TCheckGroup + AnchorSideLeft.Control = Owner + AnchorSideTop.Control = ButtonOpen + AnchorSideRight.Control = ButtonClose + AnchorSideBottom.Control = StatusBar1 + Left = 10 + Height = 105 + Top = 446 + Width = 577 + Anchors = [akTop, akLeft, akRight, akBottom] + AutoFill = True + BorderSpacing.Left = 10 + BorderSpacing.Right = 10 + Caption = 'Category' + ChildSizing.LeftRightSpacing = 6 + ChildSizing.TopBottomSpacing = 6 + ChildSizing.EnlargeHorizontal = crsHomogenousChildResize + ChildSizing.EnlargeVertical = crsHomogenousChildResize + ChildSizing.ShrinkHorizontal = crsScaleChilds + ChildSizing.ShrinkVertical = crsScaleChilds + ChildSizing.Layout = cclLeftToRightThenTopToBottom + ChildSizing.ControlsPerLine = 2 + Columns = 2 + OnItemClick = CheckGroupCategoryItemClick + TabOrder = 3 + end + object Splitter2: TSplitter + AnchorSideLeft.Control = Owner + AnchorSideTop.Control = ListView1 + AnchorSideTop.Side = asrBottom + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + AnchorSideBottom.Control = Memo1 + Cursor = crVSplit + Left = 0 + Height = 5 + Top = 220 + Width = 781 + Align = alNone + Anchors = [akLeft, akRight] + ResizeAnchor = akTop + end + object Panel1: TPanel + AnchorSideLeft.Control = Owner + AnchorSideTop.Control = Owner + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + Left = 0 + Height = 32 + Top = 0 + Width = 781 + Anchors = [akTop, akLeft, akRight] + ClientHeight = 32 + ClientWidth = 781 + TabOrder = 0 + TabStop = True + object EditSearch: TEdit + AnchorSideLeft.Control = Panel1 + AnchorSideTop.Control = Panel1 + Left = 10 + Height = 29 + Hint = 'Searches for Keywords' + Top = 1 + Width = 415 + Anchors = [akTop, akLeft, akRight] + BorderSpacing.Left = 9 + BorderSpacing.Right = 25 + OnExit = EditSearchExit + OnKeyUp = EditSearchKeyUp + ParentShowHint = False + ShowHint = True + TabOrder = 0 + Text = 'EditSearch' + end + end + object StatusBar1: TStatusBar + Left = 0 + Height = 23 + Top = 551 + Width = 781 + Panels = <> + end + object ButtonDownload: TButton + AnchorSideLeft.Control = ButtonClose + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + AnchorSideBottom.Control = ButtonClose + Left = 597 + Height = 35 + Top = 481 + Width = 179 + Anchors = [akLeft, akRight, akBottom] + BorderSpacing.Right = 5 + Caption = 'Download' + OnClick = ButtonDownloadClick + TabOrder = 6 + end + object ButtonClose: TButton + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + AnchorSideBottom.Control = StatusBar1 + Left = 597 + Height = 35 + Top = 516 + Width = 179 + Anchors = [akRight, akBottom] + BorderSpacing.Right = 5 + Caption = 'Close' + OnClick = ButtonCloseClick + TabOrder = 7 + end + object ButtonOpen: TButton + AnchorSideLeft.Control = ButtonClose + AnchorSideRight.Control = Owner + AnchorSideRight.Side = asrBottom + AnchorSideBottom.Control = ButtonDownload + Left = 597 + Height = 35 + Top = 446 + Width = 179 + Anchors = [akLeft, akRight, akBottom] + BorderSpacing.Right = 5 + Caption = 'Open' + OnClick = ButtonOpenClick + TabOrder = 8 + end +end diff --git a/components/exampleswindow/ulaz_examples.lpi b/components/exampleswindow/ulaz_examples.lpi new file mode 100644 index 0000000000..1984869e56 --- /dev/null +++ b/components/exampleswindow/ulaz_examples.lpi @@ -0,0 +1,58 @@ + + + + + + + + + + + + + <UseAppBundle Value="False"/> + <ResourceType Value="res"/> + </General> + <BuildModes> + <Item Name="Default" Default="True"/> + </BuildModes> + <PublishOptions> + <Version Value="2"/> + <UseFileFilters Value="True"/> + </PublishOptions> + <RunParams> + <FormatVersion Value="2"/> + </RunParams> + <RequiredPackages> + <Item> + <PackageName Value="IDEIntf"/> + </Item> + <Item> + <PackageName Value="LCL"/> + </Item> + </RequiredPackages> + <Units> + <Unit> + <Filename Value="ulaz_examples.pas"/> + <IsPartOfProject Value="True"/> + <ComponentName Value="FormLazExam"/> + <HasResources Value="True"/> + <ResourceBaseClass Value="Form"/> + <UnitName Value="uLaz_Examples"/> + </Unit> + <Unit> + <Filename Value="uexampledata.pas"/> + <IsPartOfProject Value="True"/> + </Unit> + </Units> + </ProjectOptions> + <CompilerOptions> + <Version Value="11"/> + <Target> + <Filename Value="ulaz_examples"/> + </Target> + <SearchPaths> + <UnitOutputDirectory Value="lib/$(TargetCPU)-$(TargetOS)"/> + </SearchPaths> + </CompilerOptions> +</CONFIG> diff --git a/components/exampleswindow/ulaz_examples.pas b/components/exampleswindow/ulaz_examples.pas new file mode 100644 index 0000000000..e8fef2c28a --- /dev/null +++ b/components/exampleswindow/ulaz_examples.pas @@ -0,0 +1,471 @@ +unit uLaz_Examples; +{ + ********************************************************************** + This file is part of a Lazarus Package, Examples Window. + + See the file COPYING.modifiedLGPL.txt, included in this distribution, + for details about the license. + ********************************************************************** + +This unit displays all the examples that it can find metadata for. At present it +looks in the LazarusDir and then the LazConfigDir (but can be made to look online). + +It scans the examples and makes Catagory Checkboxes for all the Categories it finds. + +In OnLine mode, will look for a master meta file in LazConfigDir/examples +If its not there, it will try to download one from Remote. +In either case will scan the LazConfigDir (excluding Examples ???) looking for +potential 'other' example projects, recognisable by a valid json file with an +extension of ex-meta. + +Notes - + We have a search field across the top, its requires user to press enter, + performance notwithstanding, it could be converted to update with every key press. + + David Bannon, Feb 2022 +} +{$mode objfpc}{$H+} + +{x$define ONLINE_EXAMPLES} + +interface + +uses + Classes, SysUtils, Forms, Controls, Graphics, Dialogs, StdCtrls, ComCtrls, + ExtCtrls, Interfaces, uexampledata, uConst + {$ifndef EXTESTMODE} + , IDEWindowIntf + {$endif} + ; + + +type + + { TFormLazExam } + + TFormLazExam = class(TForm) + ButtonDownload: TButton; + ButtonClose: TButton; + ButtonOpen: TButton; + CheckGroupCategory: TCheckGroup; + EditSearch: TEdit; + ListView1: TListView; + Memo1: TMemo; + Panel1: TPanel; + Splitter2: TSplitter; + StatusBar1: TStatusBar; + procedure ButtonCloseClick(Sender: TObject); + procedure ButtonDownloadClick(Sender: TObject); + procedure ButtonOpenClick(Sender: TObject); + procedure CheckGroupCategoryItemClick(Sender: TObject; Index: integer); + procedure EditSearchExit(Sender: TObject); + procedure EditSearchKeyUp(Sender: TObject; var Key: Word; Shift: TShiftState); + procedure FormCreate(Sender: TObject); + procedure FormDestroy(Sender: TObject); + procedure FormShow(Sender: TObject); + procedure ListView1Click(Sender: TObject); + procedure ListView1DblClick(Sender: TObject); + procedure ListView1SelectItem(Sender: TObject; Item: TListItem; Selected: Boolean); + private + procedure BuildSearchList(SL: TStringList; const Term: AnsiString); + // SrcDir includes name of actual dir, DestDir does not. + function CopyFiles(const Proj, SrcDir, DestDir: string): boolean; + function DirExistsCaseInSense(const APath: string; out ActualFullDir: string) : boolean; + // Passed the Full Path (with or without trailing delim) to a Project Dir, rets F if not + // present, T if Dir exists. If it finds an lpi file, rets with FFilename, else empty string. + // WriteProjectToOpen will cause a download / copy of the files. + // Sets the Regional Var, ProjectToOpen if WriteProjectToOpen is true. + // Thats triggers a Lazarus Open when this window closes. + function GetProjectFile(const APath: string; WriteProjectToOpen: boolean = false): boolean; + procedure KeyWordSearch; + function NewLVItem(const LView : TListView; const Proj, Path, KeyWords : string): TListItem; + // Displays the current content of Examples List in the listview and + // populates the Category checkboxes. + procedure LoadUpListView(); + procedure PrimeCatFilter; + public + GitDir : string; // Not needed in Lazarus Package, used in dev's tool emt + //LazConfigDir : string; // We will download examples to here. + ExamplesHome : string; // Defaults to LazConfig but user settable + RemoteRepo : string; // This is the full gitlab URL + ProjectToOpen : string; // If not empty after close, open the project named. + end; + +var + FormLazExam: TFormLazExam; + Ex : TExampleData; + +implementation + +uses LazFileUtils, LCLType, fileutil, LazLogger; + +{$R *.lfm} + +{ TFormLazExam } + + +// ------------------------ L I S T V I E W ---------------------------------- + +function TFormLazExam.NewLVItem(const LView : TListView; const Proj, Path, KeyWords : string): TListItem; +var + TheItem : TListItem; +begin + TheItem := LView.Items.Add; + TheItem.Caption := Proj; + TheItem.SubItems.Add(KeyWords); + TheItem.SubItems.Add(Path); + Result := TheItem; +end; + +procedure TFormLazExam.ListView1SelectItem(Sender: TObject; Item: TListItem; Selected: Boolean); +begin + ListView1Click(Sender); +end; + +procedure TFormLazExam.LoadUpListView(); +var + Proj, Cat, Path, KeyW : string; + Cnt : integer = 0; + KeyList : TStringList = nil; +begin + Screen.Cursor := crHourGlass; + if EditSearch.text <> rsExSearchPrompt then begin + KeyList := TStringList.Create; + BuildSearchList(KeyList, EditSearch.Text); + end; + try + if Ex.GetListData(Proj, Cat, Path, KeyW, True, KeyList) then begin + NewLVItem(ListView1, Proj, Path, KeyW); + inc(Cnt); + end; + while Ex.GetListData(Proj, Cat, Path, KeyW, False, KeyList) do begin + NewLVItem(ListView1, Proj, Path, KeyW); + inc(Cnt); + end; + finally + if KeyList <> Nil then KeyList.Free; + Screen.Cursor := crDefault; + end; + ButtonOpen.Enabled := false; + ButtonDownLoad.enabled := false; + Memo1.append(format(rsFoundExampleProjects, [Cnt])); + StatusBar1.SimpleText := format(rsFoundExampleProjects, [Cnt]); +end; + +procedure TFormLazExam.ListView1Click(Sender: TObject); +begin + if ListView1.Selected = nil then exit; // White space below entries .... + Memo1.Clear; + Memo1.append(ListView1.Selected.SubItems[1]); + Memo1.Append(Ex.GetDesc(ListView1.Selected.SubItems[1] + ListView1.Selected.Caption)); + // ListView1.Selected.Caption may be CamelCase from JSON.Name rather than path where we found it. + ButtonDownLoad.enabled := true; + //ButtonOpen.Enabled := GetProjectFile(ListView1.Selected.SubItems[1]); + ButtonOpen.Enabled := GetProjectFile(Ex.MasterMeta(True) + ListView1.Selected.Caption); +end; + +function TFormLazExam.CopyFiles(const Proj, SrcDir, DestDir : string) : boolean; +var + STL : TStringList; + St, FFname : string; + + // The Right part of St starting with Proj + function RightSide : string; + var + i : integer; + begin + result := ''; + i := St.Length; + while i > 0 do begin + if (PathDelim + lowercase(Proj) + PathDelim) = lowercase(copy(St, i, Proj.length+2)) then + exit(copy(St, i, 1000)); + dec(i); + end; + debugln('TFormLazExam.CopyFiles - failed to find [' + Proj + '] in ' + St); + end; + +begin + Result := False; + STL := FindAllFiles(SrcDir, '*', True); + try + for St in STL do begin + FFName := appendPathDelim(DestDir) + RightSide(); + + debugln('TFormLazExam.CopyFiles Forcing an ExamplesHome of ' + extractFileDir(FFname)); + debugln('TFormLazExam.CopyFiles Copying a file to ' + FFname); + debugln('TFormLazExam.CopyFiles DestDir = ' + DestDir); + debugln('TFormLazExam.CopyFiles ExamplesHome = ' + ExamplesHome); + if not ForceDirectoriesUTF8(extractFileDir(FFName)) then begin + debugln('TFormLazExam.CopyFiles - Failed to force ' + extractFileDir(FFName)); + exit; + end; + if not copyfile(St, FFname, [cffOverwriteFile]) then begin + debugln('TFormLazExam.CopyFiles - Failed to copy ' + St + ' to ' + FFName); + exit; + end; + end; + finally + STL.Free; + end; + result := true; +end; + +procedure TFormLazExam.ListView1DblClick(Sender: TObject); +begin + if ListView1.Selected = nil then exit; // White space below entries .... + if GetProjectFile(Ex.MasterMeta(True) + ListView1.Selected.Caption) then begin + if Application.MessageBox(pchar(rsRefreshExistingExample) + , pchar(ListView1.Selected.Caption) + , MB_ICONQUESTION + MB_YESNO) <> IDYES then exit; + // OK - we overwrite. Any other files user has added are not removed + end; + Screen.Cursor := crHourGlass; + try + if Ex <> nil then begin + {$ifdef ONLINE_EXAMPLES} + StatusBar1.SimpleText := rsExDownloadingProject; + Application.ProcessMessages; + EX.DownLoadDir(ListView1.Selected.SubItems[1]); + StatusBar1.SimpleText := rsExProjectDownloadedTo + ' ' + Ex.MasterMeta(True) + + ListView1.Selected.Caption; + {$else} + StatusBar1.SimpleText := rsExCopyingProject; + Application.ProcessMessages; + if copyFiles( ListView1.Selected.Caption, + ListView1.Selected.SubItems[1], Ex.MasterMeta(True)) then + StatusBar1.SimpleText := rsExProjectCopiedTo + ' ' + Ex.MasterMeta(True) + else StatusBar1.SimpleText := rsFailedToCopyFilesTo + ' ' + Ex.MasterMeta(True); + {$endif} + end; + finally + Screen.Cursor := crDefault; + end; + ButtonOpen.Enabled := GetProjectFile(Ex.MasterMeta(True) + ListView1.Selected.Caption); +end; + +// --------------------- B U T T O N S ----------------------------------------- + +procedure TFormLazExam.ButtonOpenClick(Sender: TObject); +begin + if GetProjectFile(Ex.MasterMeta(True) + ListView1.Selected.Caption, True) // Sets ProjectToOpen on success + and ProjectToOpen.IsEmpty then + showmessage(rsExNoProjectFile) + else + close; +end; + +procedure TFormLazExam.ButtonCloseClick(Sender: TObject); +begin + Close; +end; + +procedure TFormLazExam.ButtonDownloadClick(Sender: TObject); +begin + ListView1DblClick(Sender); +end; + +function TFormLazExam.GetProjectFile(const APath : string; WriteProjectToOpen : boolean = false) : boolean; +var + Info : TSearchRec; + RealDir : string; + // The project dir name may not be a case match for the Project Name. +begin + Result := DirExistsCaseInSense(APath, RealDir); + if not (Result and WriteProjectToOpen) then exit; + if FindFirst(RealDir + '*.lpi', faAnyFile, Info) = 0 then + ProjectToOpen := RealDir + Info.Name + else ProjectToOpen := ''; + FindClose(Info); +end; + +// Checks for existance of passed path, the last element of which is case Insensitive. +// Returns with the actual name of the full path if successful. +function TFormLazExam.DirExistsCaseInSense(const APath : string; out ActualFullDir : string) : boolean; +var + Info : TSearchRec; + FName : string; +begin + FName := lowercase(extractFileName(ChompPathDelim(APath))); + if FindFirst(extractFileDir(ChompPathDelim(APath))+PathDelim + '*',faDirectory, Info) = 0 then begin + try + repeat + if (Info.Attr and faDirectory) = faDirectory then + if lowercase(Info.Name) = FName then begin + ActualFullDir := extractFileDir(ChompPathDelim(APath)) + +PathDelim + Info.Name + PathDelim; + exit(True); + end; + until FindNext(Info) <> 0; + finally + FindClose(Info); + end; + end; + Result := False; +end; + +procedure TFormLazExam.CheckGroupCategoryItemClick(Sender: TObject; Index: integer); +begin + if Ex = Nil then exit; + Memo1.clear; + ListView1.Clear; + PrimeCatFilter(); + LoadUpListView(); +end; + + +// ---------------------- S E A R C H R E L A T E D -------------------------- + +// Build a StringList of the terms user has typed in, words or "groups of words" +procedure TFormLazExam.BuildSearchList(SL : TStringList; const Term : AnsiString); +var + I : integer = 1; + AWord : string = ''; + InCommas : boolean = false; +begin + while I <= length(trim(Term)) do begin + if Term[i] = '"' then begin + if InCommas then begin + SL.add(AWord); + AWord := ''; + InCommas := False; + end else begin + InCommas := true; + end; + inc(I); + continue; + end; + if Term[i] = ' ' then begin + if InCommas then + AWord := AWord + Term[i] + else begin + if AWord <> '' then begin + SL.Add(AWord); + AWord := ''; + end; + end; + inc(I); + continue; + end; + AWord := AWord + Term[i]; + inc(i); + continue; + end; + if AWord <> '' then + SL.Add(AWord); +end; + +procedure TFormLazExam.EditSearchExit(Sender: TObject); +begin + if EditSearch.Text = '' then begin + EditSearch.Hint:= rsExSearchPrompt; + EditSearch.Text := rsExSearchPrompt; + EditSearch.SelStart := 1; + EditSearch.SelLength := length(EditSearch.Text); + end; +end; + +procedure TFormLazExam.KeyWordSearch(); +begin + Memo1.clear; + ListView1.Clear; + Ex.KeyFilter := EditSearch.Text; + LoadUpListView(); +end; + +procedure TFormLazExam.EditSearchKeyUp(Sender: TObject; var Key: Word; Shift: TShiftState); +begin + // Must do this here to stop LCL from selecting the text on VK_RETURN + if Key = VK_RETURN then begin + Key := 0; + KeyWordSearch(); + end; +end; + +procedure TFormLazExam.PrimeCatFilter(); +var + i : integer; + St : string = ''; +begin + for i := 0 to CheckGroupCategory.Items.Count -1 do begin + if CheckGroupCategory.Checked[i] then + St := St + CheckGroupCategory.Items[i] + ' '; + end; + Ex.CatFilter := St; +end; + + +// -------------------- H O U S E K E E P I N G ------------------------------ + +procedure TFormLazExam.FormCreate(Sender: TObject); +begin + Caption := rsExampleProjects; + ListView1.Column[0].Caption := rsExampleName; + ListView1.Column[1].Caption := rsExampleKeyWords; + ListView1.Column[2].Caption := rsExamplePath; + ListView1.AutoSortIndicator := True; + ListView1.Column[0].SortIndicator := siDescending; + ListView1.AutoSort := True; + ListView1.SortDirection:= sdDescending; + ListView1.AutoWidthLastColumn:= True; + ListView1.ViewStyle:= vsReport; + ListView1.Column[0].AutoSize := true; + ListView1.Column[1].AutoSize := true; + ListView1.Column[2].Visible := false; + ListView1.ReadOnly := True; + EditSearch.text := rsExSearchPrompt; + Ex := nil; + // These are ObjectInspector set but I believe I cannot get OI literals set in a Package ?? + ButtonClose.Caption := rsExampleClose; + ButtonDownload.Caption := rsExampleDownLoad; + ButtonOpen.Caption := rsExampleOpen; + CheckGroupCategory.Caption := rsExampleCategory; + {$ifndef EXTESTMODE} + IDEDialogLayoutList.ApplyLayout(Self); + {$endif} +end; + +procedure TFormLazExam.FormDestroy(Sender: TObject); +begin + if Ex <> nil then Ex.Free; +end; + +procedure TFormLazExam.FormShow(Sender: TObject); +var + i : integer; +begin + Memo1.clear; + if Ex <> Nil then Ex.Free; + StatusBar1.SimpleText := rsExSearchingForExamples; + Ex := TExampleData.Create(); + Ex.GitDir := GitDir; + Ex.ExamplesHome := ExamplesHome; + Ex.RemoteRepo := RemoteRepo; + {$ifdef ONLINE_EXAMPLES} + Ex.LoadExData(FromCacheFile); + {$else} + Ex.LoadExData(FromLazSrcTree); + {$endif} + if Ex.ErrorMsg <> '' then + Showmessage(Ex.ErrorMsg) + else begin + ex.getCategoryData(CheckGroupCategory.Items); // This sets the name of all categories in the CheckGroup + for i := 0 to CheckGroupCategory.items.Count-1 do // check all the categories we found. + CheckGroupCategory.Checked[i] := true; + ListView1.Clear; + PrimeCatFilter(); + LoadUpListView(); + end; + if EditSearch.Text <> rsExSearchPrompt then + KeyWordSearch() + else EditSearch.SetFocus; + + +end; + +{ Must add a FormClose event + IDEDialogLayoutList.SaveLayout(Self); +} + + +end. +