unit fpsSearch;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, RegExpr, fpstypes, fpspreadsheet;

type
  TsConfirmReplacementResult = (crReplace, crIgnore, crAbort);

  TsConfirmReplacementEvent = procedure (Sender: TObject; AWorksheet: TsWorksheet;
    ARow, ACol: Cardinal; const ASearchText, AReplaceText: String;
    var AResult: TsConfirmReplacementResult) of object;

  TsSearchEngine = class
  private
    FWorkbook: TsWorkbook;
    FSearchText: String;
    FSearchParams: TsSearchParams;
    FReplaceParams: TsReplaceParams;
    FRegEx: TRegExpr;
    FStopping: Boolean;
    FOnConfirmReplacement: TsConfirmReplacementEvent;
  protected
    function ExecReplace(AWorksheet: TsWorksheet; ARow, ACol: Cardinal): boolean;
    function ExecSearch(var AWorksheet: TsWorksheet;
      var ARow, ACol: Cardinal): Boolean;
    procedure GotoFirst(out AWorksheet: TsWorksheet; out ARow, ACol: Cardinal);
    procedure GotoLast(out AWorksheet: TsWorksheet; out ARow, ACol: Cardinal);
    function GotoNext(var AWorksheet: TsWorksheet;
      var ARow, ACol: Cardinal): Boolean;
    function GotoNextInWorksheet(AWorksheet: TsWorksheet;
      var ARow, ACol: Cardinal): Boolean;
    function GotoPrev(var AWorksheet: TsWorksheet;
      var ARow, ACol: Cardinal): Boolean;
    function GotoPrevInWorksheet(AWorksheet: TsWorksheet;
      var ARow, ACol: Cardinal): Boolean;
    function Matches(AWorksheet: TsWorksheet; ARow, ACol: Cardinal): Boolean;
    procedure PrepareSearchText(const ASearchText: String);

  public
    constructor Create(AWorkbook: TsWorkbook);
    destructor Destroy; override;
    function FindFirst(const ASearchParams: TsSearchParams;
      out AWorksheet: TsWorksheet; out ARow, ACol: Cardinal): Boolean;
    function FindNext(const ASearchParams: TsSearchParams;
      var AWorksheet: TsWorksheet; var ARow, ACol: Cardinal): Boolean;
    function ReplaceFirst(const ASearchParams: TsSearchParams;
      const AReplaceParams: TsReplaceParams;
      out AWorksheet: TsWorksheet; out ARow, ACol: Cardinal): Boolean;
    function ReplaceNext(const ASearchParams: TsSearchParams;
      const AReplaceParams: TsReplaceParams;
      var AWorksheet: TsWorksheet; var ARow, ACol: Cardinal): Boolean;

    property OnConfirmReplacement: TsConfirmReplacementEvent
      read FOnConfirmReplacement write FOnConfirmReplacement;
  end;

implementation

uses
  lazutf8, {%H-}fpsPatches;

constructor TsSearchEngine.Create(AWorkbook: TsWorkbook);
begin
  inherited Create;
  FWorkbook := AWorkbook;
end;

destructor TsSearchEngine.Destroy;
begin
  FreeAndNil(FRegEx);
  inherited Destroy;
end;

function TsSearchEngine.ExecReplace(AWorksheet: TsWorksheet; ARow, ACol: Cardinal) : Boolean;
var
  s: String;
  flags: TReplaceFlags;
  confirmation: TsConfirmReplacementResult;
begin
  if roConfirm in FReplaceParams.Options then
  begin
    if Assigned(FOnConfirmReplacement) then
    begin
      confirmation := crReplace;
      FOnConfirmReplacement(self, AWorksheet, ARow, ACol,
        FSearchParams.SearchText, FReplaceParams.ReplaceText, confirmation);
      case confirmation of
        crReplace: ;
        crIgnore : exit(false);
        crAbort  : begin FStopping := true; exit(false); end;
      end;
    end else
      raise EFPSpreadsheet.Create('[TsSearchEngine.ExecReplace] OnConfirmReplacement handler needed.');
  end;

  if roReplaceEntireCell in FReplaceParams.Options then
    AWorksheet.WriteCellValueAsString(ARow, ACol, FReplaceParams.ReplaceText)
  else begin
    s := AWorksheet.ReadAsText(ARow, ACol);
    if soCompareEntireCell in FSearchParams.Options then
      AWorksheet.WriteCellValueAsString(ARow, ACol, FReplaceParams.ReplaceText)
    else
    begin
      flags := [];
      if not (soMatchCase in FSearchParams.Options) then
        Include(flags, rfIgnoreCase);
      s := UTF8StringReplace(s, FSearchparams.SearchText, FReplaceParams.ReplaceText, flags);
      AWorksheet.WriteCellValueAsString(ARow, ACol, s);
      // to do: RegEx to be added
    end;
  end;

  Result := true;
end;

function TsSearchEngine.ExecSearch(var AWorksheet: TsWorksheet;
  var ARow, ACol: Cardinal): Boolean;
var
  complete: boolean;
  r, c: Cardinal;
  sheet: TsWorksheet;
begin
  sheet := AWorksheet;
  r := ARow;
  c := ACol;
  complete := false;
  while (not complete) and (not Matches(AWorksheet, ARow, ACol)) do
  begin
    if soBackward in FSearchParams.Options then
      complete := not GotoPrev(AWorkSheet, ARow, ACol)
    else
      complete := not GotoNext(AWorkSheet, ARow, ACol);
    // Avoid infinite loop if search phrase does not exist in document.
    if (AWorksheet = sheet) and (ARow = r) and (ACol = c) then
      complete := true;
  end;
  Result := not complete;
  if Result then
  begin
    FWorkbook.SelectWorksheet(AWorksheet);
    AWorksheet.SelectCell(ARow, ACol);
  end else
  begin
    AWorksheet := nil;
    ARow := UNASSIGNED_ROW_COL_INDEX;
    ACol := UNASSIGNED_ROW_COL_INDEX;
  end;
end;

function TsSearchEngine.FindFirst(const ASearchParams: TsSearchParams;
  out AWorksheet: TsWorksheet; out ARow, ACol: Cardinal): Boolean;
begin
  FSearchParams := ASearchParams;
  PrepareSearchText(FSearchParams.SearchText);

  if soBackward in FSearchParams.Options then
    GotoLast(AWorksheet, ARow, ACol) else
    GotoFirst(AWorksheet, ARow, ACol);

  Result := ExecSearch(AWorksheet, ARow, ACol);
end;

function TsSearchEngine.FindNext(const ASearchParams: TsSearchParams;
  var AWorksheet: TsWorksheet; var ARow, ACol: Cardinal): Boolean;
begin
  FSearchParams := ASearchParams;
  PrepareSearchText(FSearchParams.SearchText);

  if soBackward in FSearchParams.Options then
    GotoPrev(AWorksheet, ARow, ACol) else
    GotoNext(AWorksheet, ARow, ACol);

  Result := ExecSearch(AWorksheet, ARow, ACol);
end;

procedure TsSearchEngine.GotoFirst(out AWorksheet: TsWorksheet;
  out ARow, ACol: Cardinal);
begin
  if soEntireDocument in FSearchParams.Options then
    // Search entire document forward from start
    case FSearchParams.Within of
      swWorkbook :
        begin
          AWorksheet := FWorkbook.GetWorksheetByIndex(0);
          ARow := 0;
          ACol := 0;
        end;
      swWorksheet:
        begin
          AWorksheet := FWorkbook.ActiveWorksheet;
          ARow := 0;
          ACol := 0;
        end;
      swColumn:
        begin
          AWorksheet := FWorkbook.ActiveWorksheet;
          ARow := 0;
          ACol := AWorksheet.ActiveCellCol;
        end;
      swRow:
        begin
          AWorksheet := FWorkbook.ActiveWorksheet;
          ARow := AWorksheet.ActiveCellRow;
          ACol := 0;
        end;
    end
  else
  begin
    // Search starts at active cell
    AWorksheet := FWorkbook.ActiveWorksheet;
    if AWorksheet = nil then AWorksheet := FWorkbook.GetFirstWorksheet;
    ARow := AWorksheet.ActiveCellRow;
    ACol := AWorksheet.ActiveCellCol;
  end;
end;

procedure TsSearchEngine.GotoLast(out AWorksheet: TsWorksheet;
  out ARow, ACol: Cardinal);
begin
  if soEntireDocument in FSearchParams.Options then
    // Search entire document backward from end
    case FSearchParams.Within of
      swWorkbook :
        begin
          AWorksheet := FWorkbook.GetWorksheetByIndex(FWorkbook.GetWorksheetCount-1);
          ARow := AWorksheet.GetLastRowIndex;
          ACol := AWorksheet.GetLastColIndex;
        end;
      swWorksheet:
        begin
          AWorksheet := FWorkbook.ActiveWorksheet;
          ARow := AWorksheet.GetLastRowIndex;
          ACol := AWorksheet.GetLastColIndex;
        end;
      swColumn:
        begin
          AWorksheet := FWorkbook.ActiveWorksheet;
          ARow := AWorksheet.GetLastRowIndex;
          ACol := AWorksheet.ActiveCellCol;
        end;
      swRow:
        begin
          AWorksheet := FWorkbook.ActiveWorksheet;
          ARow := AWorksheet.ActiveCellRow;
          ACol := AWorksheet.GetLastColIndex;
        end;
    end
  else
  begin
    // Search starts at active cell
    AWorksheet := FWorkbook.ActiveWorksheet;
    ARow := AWorksheet.ActiveCellRow;
    ACol := AWorksheet.ActiveCellCol;
  end;
end;

function TsSearchEngine.GotoNext(var AWorksheet: TsWorksheet;
  var ARow, ACol: Cardinal): Boolean;
var
  idx: Integer;
begin
  Result := true;

  if GotoNextInWorksheet(AWorksheet, ARow, ACol) then
    exit;

  case FSearchParams.Within of
    swWorkbook:
      begin
        // Need to go to next sheet
        idx := FWorkbook.GetWorksheetIndex(AWorksheet) + 1;
        if idx < FWorkbook.GetWorksheetCount then
        begin
          AWorksheet := FWorkbook.GetWorksheetByIndex(idx);
          ARow := 0;
          ACol := 0;
          exit;
        end;
        // Continue search with first worksheet
        if (soWrapDocument in FSearchParams.Options) then
        begin
          AWorksheet := FWorkbook.GetWorksheetByIndex(0);
          ARow := 0;
          ACol := 0;
          exit;
        end;
      end;

    swWorksheet:
      if soWrapDocument in FSearchParams.Options then begin
        ARow := 0;
        ACol := 0;
        exit;
      end;

    swColumn:
      if soWrapDocument in FSearchParams.Options then begin
        ARow := 0;
        ACol := AWorksheet.ActiveCellCol;
        exit;
      end;

    swRow:
      if soWrapDocument in FSearchParams.Options then begin
        ARow := AWorksheet.ActiveCellRow;
        ACol := 0;
        exit;
      end;
  end;  // case

  Result := false;
end;


function TsSearchEngine.GotoNextInWorksheet(AWorksheet: TsWorksheet;
  var ARow, ACol: Cardinal): Boolean;
begin
  Result := true;
  if (soAlongRows in FSearchParams.Options) or (FSearchParams.Within = swRow) then
  begin
    inc(ACol);
    if ACol <= AWorksheet.GetLastColIndex then
      exit;
    if (FSearchParams.Within <> swRow) then
    begin
      ACol := 0;
      inc(ARow);
      if ARow <= AWorksheet.GetLastRowIndex then
        exit;
    end;
  end else
  if not (soAlongRows in FSearchParams.Options) or (FSearchParams.Within = swColumn) then
  begin
    inc(ARow);
    if ARow <= AWorksheet.GetLastRowIndex then
      exit;
    if (FSearchParams.Within <> swColumn) then
    begin
      ARow := 0;
      inc(ACol);
      if (ACol <= AWorksheet.GetLastColIndex) then
        exit;
    end;
  end;
  // We reached the last cell, there is no "next" cell in this sheet
  Result := false;
end;

function TsSearchEngine.GotoPrev(var AWorksheet: TsWorksheet;
  var ARow, ACol: Cardinal): Boolean;
var
  idx: Integer;
begin
  Result := true;

  if GotoPrevInWorksheet(AWorksheet, ARow, ACol) then
    exit;

  case FSearchParams.Within of
    swWorkbook:
      begin
        // Need to go to previous sheet
        idx := FWorkbook.GetWorksheetIndex(AWorksheet) - 1;
        if idx >= 0 then
        begin
          AWorksheet := FWorkbook.GetWorksheetByIndex(idx);
          ARow := AWorksheet.GetLastRowIndex;
          ACol := AWorksheet.GetlastColIndex;
          exit;
        end;
        if (soWrapDocument in FSearchParams.Options) then
        begin
          AWorksheet := FWorkbook.GetWorksheetByIndex(FWorkbook.GetWorksheetCount-1);
          ARow := AWorksheet.GetLastRowIndex;
          ACol := AWorksheet.GetLastColIndex;
          exit;
        end;
      end;

    swWorksheet:
      if soWrapDocument in FSearchParams.Options then
      begin
        ARow := AWorksheet.GetLastRowIndex;
        ACol := AWorksheet.GetLastColIndex;
        exit;
      end;

    swColumn:
      if soWrapDocument in FSearchParams.Options then
      begin
        ARow := AWorksheet.GetLastRowIndex;
        ACol := AWorksheet.ActiveCellCol;
        exit;
      end;

    swRow:
      if soWrapDocument in FSearchParams.Options then
      begin
        ARow := AWorksheet.ActiveCellRow;
        ACol := AWorksheet.GetLastColIndex;
        exit;
      end;
  end;  // case

  Result := false;
end;

function TsSearchEngine.GotoPrevInWorksheet(AWorksheet: TsWorksheet;
  var ARow, ACol: Cardinal): Boolean;
begin
  Result := true;
  if (soAlongRows in FSearchParams.Options) or (FSearchParams.Within = swRow) then
  begin
    if ACol > 0 then begin
      dec(ACol);
      exit;
    end;
    if (FSearchParams.Within <> swRow) then
    begin
      ACol := AWorksheet.GetLastColIndex;
      if ARow > 0 then
      begin
        dec(ARow);
        exit;
      end;
    end;
  end else
  if not (soAlongRows in FSearchParams.Options) or (FSearchParams.Within = swColumn) then
  begin
    if ARow > 0 then begin
      dec(ARow);
      exit;
    end;
    if (FSearchParams.Within <> swColumn) then
    begin
      ARow := AWorksheet.GetlastRowIndex;
      if ACol > 0 then
      begin
        dec(ACol);
        exit;
      end;
    end;
  end;
  // We reached the first cell, there is no "previous" cell
  Result := false;
end;

function TsSearchEngine.Matches(AWorksheet: TsWorksheet; ARow, ACol: Cardinal): Boolean;
var
  cell: PCell;
  txt: String;
begin
  cell := AWorksheet.FindCell(ARow, ACol);
  txt := '';
  if cell <> nil then
  begin
    if (soSearchInComment in FSearchParams.Options) then
      txt := AWorksheet.ReadComment(cell)
    else
      txt := AWorksheet.ReadAsText(cell);
  end;

  if txt = '' then
    exit(false);

  if soRegularExpr in FSearchParams.Options then
    Result := FRegEx.Exec(txt)
  else
  begin
    if not (soMatchCase in FSearchParams.Options) then
      txt := UTF8Lowercase(txt);
    if soCompareEntireCell in FSearchParams.Options then
      exit(txt = FSearchText);
    if pos(FSearchText, txt) > 0 then
      exit(true);
    Result := false;
  end;
end;

procedure TsSearchEngine.PrepareSearchText(const ASearchText: String);
begin
  if soRegularExpr in FSearchParams.Options then
  begin
    FreeAndNil(FRegEx);
    FRegEx := TRegExpr.Create;
    FRegEx.Expression := ASearchText
  end else
  if (soMatchCase in FSearchParams.Options) then
    FSearchText := ASearchText else
    FSearchText := UTF8Lowercase(ASearchText);
end;

function TsSearchEngine.ReplaceFirst(const ASearchParams: TsSearchParams;
  const AReplaceParams: TsReplaceParams; out AWorksheet: TsWorksheet;
  out ARow, ACol: Cardinal): Boolean;
var
  r,c: Cardinal;
  sheet: TsWorksheet;
begin
  FStopping := false;

  // Lock the visual components in case of "replace all" and "no confirmation"
  if AReplaceParams.Options * [roReplaceAll, roConfirm] = [roReplaceAll] then
    FWorkbook.DisableNotifications;

  Result := FindFirst(ASearchParams, AWorksheet, ARow, ACol);
  r := ARow;
  c := ACol;
  sheet := AWorksheet;

  if Result then
  begin
    FReplaceParams := AReplaceParams;
    Result := ExecReplace(AWorksheet, ARow, ACol);
    if roReplaceAll in FReplaceParams.Options then
    begin
      while (not FStopping) and FindNext(FSearchParams, AWorksheet, ARow, ACol) do
      begin
        r := ARow;
        c := ACol;
        sheet := AWorksheet;
        ExecReplace(AWorksheet, ARow, ACol);
      end;
    end;
  end;

  // Unlock the visual components in case of "replace all" and "no confirmation"
  // and select the last replaced cell
  if AReplaceParams.Options * [roReplaceAll, roConfirm] = [roReplaceAll] then
  begin
    FWorkbook.EnableNotifications;
    if Result then
    begin
      FWorkbook.SelectWorksheet(sheet);
      sheet.SelectCell(r, c);
    end;
  end;
end;

function TsSearchEngine.ReplaceNext(const ASearchParams: TsSearchParams;
  const AReplaceParams: TsReplaceParams; var AWorksheet: TsWorksheet;
  var ARow, ACol: Cardinal): Boolean;
var
  r, c: Cardinal;
  sheet: TsWorksheet;
begin
  FStopping := false;

  // Lock the visual components in case of "replace all" and "no confirmation"
  if AReplaceParams.Options * [roReplaceAll, roConfirm] = [roReplaceAll] then
    FWorkbook.DisableNotifications;

  Result := FindNext(ASearchParams, AWorksheet, ARow, ACol);
  r := ARow;
  c := ACol;
  sheet := AWorksheet;

  if Result then
  begin
    FReplaceParams := AReplaceParams;
    Result := ExecReplace(AWorksheet, ARow, ACol);
    if roReplaceAll in FReplaceParams.Options then
    begin
      while (not FStopping) and FindNext(FSearchParams, AWorksheet, ARow, ACol) do
      begin
        r := ARow;
        c := ACol;
        sheet := AWorksheet;
        ExecReplace(AWorksheet, ARow, ACol);
      end;
    end;
  end;

  // Unlock the visual components in case of "replace all" and "no confirmation"
  // and select the last replaced cell
  if AReplaceParams.Options * [roReplaceAll, roConfirm] = [roReplaceAll] then
  begin
    FWorkbook.EnableNotifications;
    if Result then
    begin
      FWorkbook.SelectWorksheet(sheet);
      sheet.SelectCell(r, c);
    end;
  end;
end;

end.