unit CS.Database.IO.Legacy.UserDB;
interface
uses
  // Delphi
  SysUtils,
  XMLIntf,
  // 3rd party
  Collections.Dictionaries,
  // Project
  CS.Database.Core.SnippetsTable,
  CS.Database.Types,
  CS.Utils.Dates,
  UExceptions,
  UXMLDocumentEx;
type
  TDBLegacyUserDBReader = class(TObject)
  strict private
    const
      // Database file name
      DatabaseFileName = 'database.xml';
      // watermark (never changes for all versions)
      Watermark = '531257EA-1EE3-4B0F-8E46-C6E7F7140106';
      // supported file format versions
      EarliestVersion = 1;
      LatestVersion = 6;
  strict private
    var
      fDBPath: string;
      fXMLDoc: IXMLDocumentEx;  // Extended XML document object
      fVersion: Integer;
      fCategoryMap: TDictionary<string,string>;
    procedure HandleException(E: Exception);
    function LegacySnippetName(SnippetNode: IXMLNode): string;
    procedure OpenXMLDoc;
    procedure ReadCategoryInfo;
    procedure ReadSnippets(const ATable: TDBSnippetsTable);
    procedure LoadSnippet(SnippetNode: IXMLNode;
      const ATable: TDBSnippetsTable);
    procedure LoadSnippetProperties(SnippetNode: IXMLNode;
      const ASnippet: TDBSnippet);
    function GetLastModifiedFileDate(ATable: TDBSnippetsTable): TUTCDateTime;
  public
    constructor Create(const DBPath: string);
    destructor Destroy; override;
    procedure Load(const ATable: TDBSnippetsTable;
      out ALastModified: TUTCDateTime);
  end;
  EDBLegacyUserDBReader = class(ECodeSnip);
implementation
uses
  // Delphi
  Character,
  Classes,
  IOUtils,
  ActiveX,
  XMLDom,
  // 3rd party
  Collections.Base,
  // Project
  CS.Database.SnippetLinks,
  CS.Database.Snippets,
  CS.Database.Tags,
  CS.Markup,
  CS.SourceCode.Languages,
  ActiveText.UMain,
  Compilers.UGlobals,
  DB.USnippetKind,
  UComparers,
  UConsts,
  UDOSDateTime,
  UIOUtils,
  UIStringList,
  USnippetExtraHelper,
  UStructs,
  UStrUtils,
  UXMLDocConsts,
  UXMLDocHelper;
{ TDBLegacyUserDBReader }
resourcestring
  // Error message
  sMissingNode = 'Document has no %s node.';
  sMissingSource = 'Source code file name missing for legacy snippet named '
    + '"%s"';
  sFileNotFound = 'Database file "%s" missing.';
constructor TDBLegacyUserDBReader.Create(const DBPath: string);
var
  StrRules: TRules<string>;
begin
  inherited Create;
  fDBPath := DBPath;
  StrRules := TRules<string>.Create(
    TTextComparer.Create, TTextEqualityComparer.Create
  );
  fCategoryMap := TDictionary<string,string>.Create(
    StrRules, StrRules
  );
  fVersion := -1; // dummy value changed by OpenXMLDoc when Load is called
  // For some reason we must call OleInitialize here rather than in
  // initialization section
  OleInitialize(nil);
  fXMLDoc := TXMLDocHelper.CreateXMLDoc;
end;
destructor TDBLegacyUserDBReader.Destroy;
begin
  fCategoryMap.Free;
  inherited;
end;
function TDBLegacyUserDBReader.GetLastModifiedFileDate(
  ATable: TDBSnippetsTable): TUTCDateTime;
var
  Snippet: TDBSnippet;
begin
  Result := TUTCDateTime.CreateNull;
  for Snippet in ATable do
  begin  
    if Result < Snippet.GetModified then
      Result := Snippet.GetModified;  
  end;
  if Result.IsNull then
    Result := TUTCDateTime.Now;
end;
procedure TDBLegacyUserDBReader.HandleException(E: Exception);
resourcestring
  sXMLError = 'Error reading database file XML:' + EOL2 + '%s';
  sParseError = 'Error parsing database file:' + EOL2 + '%s';
  sSourceFileError = 'Error reading source code file:' + EOL2 + '%s';
begin
  if (E is EXMLDocError) or (E is ECodeSnipXML) or (E is EDOMParseError) then
    raise EDBLegacyUserDBReader.CreateFmt(sXMLError, [E.Message]);
  if (E is EDBSnippetID) or (E is EDBTag) then
    raise EDBLegacyUserDBReader.CreateFmt(sParseError, [E.Message]);
  if (E is EIOUtils) or (E is EStreamError) then
    raise EDBLegacyUserDBReader.CreateFmt(sSourceFileError, [E.Message]);
  raise E;
end;
function TDBLegacyUserDBReader.LegacySnippetName(SnippetNode: IXMLNode):
  string;
begin
  Result := SnippetNode.Attributes[cSnippetNameAttr];
end;
procedure TDBLegacyUserDBReader.Load(const ATable: TDBSnippetsTable;
  out ALastModified: TUTCDateTime);
begin
  ATable.Clear;
  try
    OpenXMLDoc;
    ReadCategoryInfo;
    ReadSnippets(ATable);
    ALastModified := GetLastModifiedFileDate(ATable);
  except
    ATable.Clear;
    if ExceptObject is Exception then
      HandleException(ExceptObject as Exception);
  end;
end;
procedure TDBLegacyUserDBReader.LoadSnippet(SnippetNode: IXMLNode;
  const ATable: TDBSnippetsTable);
var
  Snippet: TDBSnippet;
begin
  Snippet := TDBSnippet.Create(
    TDBSnippetID.Create(LegacySnippetName(SnippetNode))
  );
  try
    LoadSnippetProperties(SnippetNode, Snippet);
    ATable.Add(Snippet);
  except
    Snippet.Free;
    raise;
  end;
end;
procedure TDBLegacyUserDBReader.LoadSnippetProperties(SnippetNode: IXMLNode;
  const ASnippet: TDBSnippet);
  {TODO: Remove following routine when routine of same name becomes available in
         UStrUtils. }
  function StrIsBlank(const S: string): Boolean;
  var
    Ch: Char;
  begin
    if S = EmptyStr then
      Exit(True);
    for Ch in S do
      if not TCharacter.IsWhiteSpace(Ch) then
        Exit(False);
    Result := True;
  end;
  // Gets text of property with given sub-tag of current snippet node in XML 
  // document.
  function GetPropertyText(const PropTagName: string): string;
  begin
    Result := TXMLDocHelper.GetSubTagText(fXMLDoc, SnippetNode, PropTagName);
  end;
  // Returns value of current snippet'ss Tags property from data in XML 
  // document. A single tag is returned which uses as its text the description 
  // of the legacy snippet's category.
  function GetTagsProperty: IDBTagList;
  var
    CatID: string;
    CatDesc: string;
  begin
    Result := TDBTagList.Create;
    CatID := GetPropertyText(cCatIDNode);  
    if not fCategoryMap.TryGetValue(CatID, CatDesc) then
      Exit;
    Result.Add(TDBTag.Create(CatDesc));
  end;
  // Returns the name of the file containing the snippet's source code.
  function GetSourceCodeFileName: string;
  var
    DataFileName: string; // base name of source code file
  begin
    DataFileName := GetPropertyText(cSourceCodeFileNode);
    if DataFileName = '' then
      raise EDBLegacyUserDBReader.CreateFmt(
        sMissingSource, [ASnippet.GetID.ToString]
      );
    Result := IncludeTrailingPathDelimiter(fDBPath) + DataFileName;
  end;
  // Returns the date and time the snippet's source code file was last saved.
  function GetLastUpdateDate: TUTCDateTime;
  var
    DOSDate: IDOSDateTime;
  begin
    DOSDate := TDOSDateTimeFactory.CreateFromFile(GetSourceCodeFileName);
    if DOSDate.DateStamp = -1 then
      Exit(TUTCDateTime.CreateNull);
    Result := TUTCDateTime.CreateFromLocalDateTime(
      FileDateToDateTime(DOSDate.DateStamp)
    );
  end;
  // Returns the snippet's source code, read from the data file referenced in 
  // the XML document.
  function GetSourceCodeProperty: string;
  var
    Encoding: TEncoding;
  begin
    // Before database v5, source code files used default ANSI encoding. From v5 
    // UTF-8 with no BOM was used.
    if fVersion < 5 then
      Encoding := TEncoding.Default
    else
      Encoding := TEncoding.UTF8;
    Result := TFileIO.ReadAllText(GetSourceCodeFileName, Encoding, False);
  end;
  // Returns the value of the XML document's <standard-format> node. Returns
  // False if there is no such node.
  function GetStandardFormatValue: Boolean;
    {Gets value of standard format node.
      @return True if standard format, False if not.
    }
  begin
    Result := TXMLDocHelper.GetStandardFormat(
      fXMLDoc, SnippetNode, False
    );
  end;
  // Returns the value of the snippet's Kind property. This is a direct match
  // with legacy snippet's property of the same name.
  function GetKindProperty: TDBSnippetKind;
    {Gets value of Kind node.
      @return Kind that matches node value.
    }
  var
    Default: TSnippetKind;            // default value
    LegacySnippetKind: TSnippetKind;  //
  begin
    // In earlier file format versions we have no Kind node, so we calculate
    // kind from StandardFormat value. If Kind node is present StandardFormat is
    // ignored.
    if GetStandardFormatValue then
      Default := skRoutine
    else
      Default := skFreeform;
    { TODO: change TXMLDocHelper.GetSnippetKind to return TDBSnippetKind to get
            rid of this convoluted conversion to TDBSnippetKind. }
    LegacySnippetKind := TXMLDocHelper.GetSnippetKind(
      fXMLDoc, SnippetNode, Default
    );
    case LegacySnippetKind of
      TSnippetKind.skRoutine: Result := TDBSnippetKind.skRoutine;
      TSnippetKind.skConstant: Result := TDBSnippetKind.skConstant;
      TSnippetKind.skTypeDef: Result := TDBSnippetKind.skTypeDef;
      TSnippetKind.skUnit: Result := TDBSnippetKind.skUnit;
      TSnippetKind.skClass: Result := TDBSnippetKind.skClass;
    else
       Result := TDBSnippetKind.skFreeform;
    end;
  end;
  // Returns mark-up of snippet's Notes property. This value is derived from
  // either the legacy snippet's Comments and Credits properties or from its
  // Extra property.
  function GetNotesProperty: TMarkup;
  var
    ActiveText: IActiveText;
    REML: string;
  begin
    // We get extra data from different nodes depending on file version
    try
      if fVersion = 1 then
      begin
        // version 1: build Notes from comments, credits and credits URL nodes.
        ActiveText := TSnippetExtraHelper.BuildActiveText(
          GetPropertyText(cCommentsNode),
          GetPropertyText(cCreditsNode),
          GetPropertyText(cCreditsUrlNode)
        );
      end
      else
      begin
        // version 2 and later: build Notes from Extra node
        REML := GetPropertyText(cExtraNode);
        if StrIsBlank(REML) then
          ActiveText := TActiveTextFactory.CreateActiveText
        else
          // Following conversion works with all versions of REML from v1 to v3
          ActiveText := TSnippetExtraHelper.BuildActiveText(REML);
      end;
      if ActiveText.IsEmpty then
        Result := TMarkup.CreateEmpty
      else if ActiveText.IsPlainText then
        Result := TMarkup.Create(ActiveText.ToString, mkPlainText)
      else
        // Active text is converted to REML v3 regardless of REML version used
        // in database file.
        Result := TMarkup.Create(
          TSnippetExtraHelper.BuildREMLMarkup(ActiveText), mkREML3
        );
    except
      // error: provide an empty property value
      Result := TMarkup.CreateEmpty;
    end;
  end;
  // Returns markup of snippets Description property. This is derived from the
  // similar property of the legacy snippet.
  function GetDescriptionProperty: TMarkup;
  var
    Desc: string; // text read from description node
  begin
    Desc := GetPropertyText(cDescriptionNode);
    if not StrIsBlank(Desc) then
    begin
      if fVersion < 6 then
        // versions before 6: description is stored as plain text
        Result := TMarkup.Create(Desc, mkPlainText)
      else
        // version 6 & later: description is stored as REML v3
        Result := TMarkup.Create(Desc, mkREML3);
    end
    else
      Result := TMarkup.CreateEmpty;
  end;
  // Returns the text of the snippet's Title property. This is derived from the
  // the legacy snippet's DisplayName or, if that is not provided, its Name
  // property.
  function GetTitleProperty: string;
  begin
    Result := GetPropertyText(cDisplayNameNode);
    if StrIsBlank(Result) then
      Result := LegacySnippetName(SnippetNode);
  end;
  // Returns the snippet's LanguageID property value which is deduced to be
  // either Pascal or Text depending on whether the legacy snippet was to be
  // highlighted or not.
  function GetLanguageIDProperty: TSourceCodeLanguageID;
  begin
    if TXMLDocHelper.GetHiliteSource(fXMLDoc, SnippetNode, True) then
      Result := TSourceCodeLanguageID.Create('Pascal')
    else
      Result := TSourceCodeLanguageID.Create('Text');
  end;
  // Converts the given list of legacy snippet names into a list of snippets
  // with IDs based on the legacy names.
  function ConvertSnippetIDList(LegacyNameList: IStringList): IDBSnippetIDList;
  var
    LegacyName: string;
  begin
    Result := TDBSnippetIDList.Create;
    for LegacyName in LegacyNameList do
      Result.Add(TDBSnippetID.Create(LegacyName));
  end;
  // Reads a list of Pascal names from the given sub-tag of the current
  // snippet's node in the XML document.
  function GetPascalNameListFor(const SubTag: string): IStringList;
  begin
    Result := TIStringList.Create;
    TXMLDocHelper.GetPascalNameList(
      fXMLDoc, fXMLDoc.FindFirstChildNode(SnippetNode, SubTag), Result
    );
  end;
  // Returns the snippet's CompileResults property value. This is derived from
  // the legacy snippet's own CompileResult property, which has a different
  // format to that of the new snippet.
  function GetCompileResultsProperty: TDBCompileResults;
  var
    LegacyCompileResults: TCompileResults;
    CompilerID: TCompilerID;
    Succeeds, Fails: TCompilerIDs;
  begin
    LegacyCompileResults := TXMLDocHelper.GetCompilerResults(
      fXMLDoc, SnippetNode
    );
    Succeeds := [];
    Fails := [];
    for CompilerID := Low(TCompilerID) to High(TCompilerID) do
    begin
      if LegacyCompileResults[CompilerID] in [crSuccess, crWarning] then
        Include(Succeeds, CompilerID)
      else if LegacyCompileResults[CompilerID] = crError then
        Include(Fails, CompilerID);
    end;
    Result := TDBCompileResults.Create(Succeeds, Fails);
  end;
  // ---------------------------------------------------------------------------
begin
  ASnippet.SetCreated(GetLastUpdateDate);
  ASnippet.SetModified(ASnippet.GetCreated);
  ASnippet.SetTitle(GetTitleProperty);
  ASnippet.SetDescription(GetDescriptionProperty);
  ASnippet.SetSourceCode(GetSourceCodeProperty);
  ASnippet.SetLanguageID(GetLanguageIDProperty);
  ASnippet.SetRequiredModules(GetPascalNameListFor(cUnitsNode));
  ASnippet.SetRequiredSnippets(
    ConvertSnippetIDList(GetPascalNameListFor(cDependsNode))
  );
  ASnippet.SetXRefs(
    ConvertSnippetIDList(GetPascalNameListFor(cXRefNode))
  );
  ASnippet.SetNotes(GetNotesProperty);
  ASnippet.SetKind(GetKindProperty);
  ASnippet.SetCompileResults(GetCompileResultsProperty);
  ASnippet.SetTags(GetTagsProperty);
  // We create a link back to the snippet's original ID in case it might be
  // useful. We use the special "LegacyDB" synch space.
  ASnippet.SetLinkInfo(
    TSnippetLinkInfo.Create(
      TSnippetSynchSpaceIDs.LegacyDB,
      TDBSnippetID.Create(LegacySnippetName(SnippetNode))
    )
  );
  // Note that the snippet's TestInfo and Starred properties have no equivalent
  // property in legacy snippets, so the values are not set here: defaults are
  // acceptable.
end;
procedure TDBLegacyUserDBReader.OpenXMLDoc;
var
  XMLFile: string;
begin
  XMLFile := IncludeTrailingPathDelimiter(fDBPath) + DatabaseFileName;
  if not TFile.Exists(XMLFile) then
    raise EDBLegacyUserDBReader.CreateFmt(sFileNotFound, [DatabaseFileName]);
  fXMLDoc.LoadFromFile(XMLFile);
  fXMLDoc.Active := True;
  TXMLDocHelper.ValidateProcessingInstr(fXMLDoc);
  fVersion := TXMLDocHelper.ValidateRootNode(
    fXMLDoc,
    cUserDataRootNode,
    Watermark,
    TRange.Create(EarliestVersion, LatestVersion)
  );
  // Both a categories and a snippets node must exist
  if fXMLDoc.FindNode(cUserDataRootNode + '\' + cCategoriesNode) = nil then
    raise EDBLegacyUserDBReader.CreateFmt(sMissingNode, [cCategoriesNode]);
  if fXMLDoc.FindNode(cUserDataRootNode + '\' + cSnippetsNode) = nil then
    raise EDBLegacyUserDBReader.CreateFmt(sMissingNode, [cSnippetsNode]);
end;
procedure TDBLegacyUserDBReader.ReadCategoryInfo;
var
  CatListNode: IXMLNode;
  CatNodes: IXMLSimpleNodeList;
  CatNode: IXMLNode;
  CatID: string;
  CatDesc: string;
begin
  // Find <categories> node
  CatListNode := fXMLDoc.FindNode(cUserDataRootNode + '\' + cCategoriesNode);
  if not Assigned(CatListNode) then
    raise EDBLegacyUserDBReader.CreateFmt(sMissingNode, [cCategoriesNode]);
  // Find all <category> nodes in <categories>
  CatNodes := fXMLDoc.FindChildNodes(CatListNode, cCategoryNode);
  fCategoryMap.Clear;
  for CatNode in CatNodes do
  begin
    // Category ID is attribute of <category> node
    CatID := CatNode.Attributes[cCategoryIdAttr];
    if not fCategoryMap.ContainsKey(CatID) then
    begin
      // Category description is in <description> sub-tag of <category>
      CatDesc := TXMLDocHelper.GetSubTagText(
        fXMLDoc, CatNode, cDescriptionNode
      );
      fCategoryMap.Add(CatID, CatDesc);
    end;
  end;
end;
procedure TDBLegacyUserDBReader.ReadSnippets(const ATable: TDBSnippetsTable);
var
  SnippetListNode: IXMLNode;
  SnippetNodes: IXMLSimpleNodeList;
  SnippetNode: IXMLNode;
begin
  // Find <routines> SnippetNode
  SnippetListNode := fXMLDoc.FindNode(cUserDataRootNode + '\' + cSnippetsNode);
  if not Assigned(SnippetListNode) then
    raise EDBLegacyUserDBReader.CreateFmt(sMissingNode, [cSnippetsNode]);
  // Find all <routine> nodes in <categories>
  SnippetNodes := fXMLDoc.FindChildNodes(SnippetListNode, cSnippetNode);
  for SnippetNode in SnippetNodes do
    LoadSnippet(SnippetNode, ATable);
end;
end.