Codeunit 1639 Office Line Generation

App
Base Application
Namespace
Microsoft.CRM.Outlook
Versions
17-28

Events, 2

Versions171819202122232425262728

Source29

Source in 29

src/Layers/W1/BaseApp/CRM/Outlook/OfficeLineGeneration.Codeunit.al607 lines, Copyright (c) Microsoft Corporation. MIT

// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.CRM.Outlook;

using Microsoft.Inventory.Item;
using Microsoft.Purchases.Document;
using Microsoft.Sales.Document;
using System;

codeunit 1639 "Office Line Generation"
{
    // This codeunit contains algorithms for finding item references within text.


    trigger OnRun()
    begin
    end;

    var
        NumbersTxt: Label 'one|two|three|four|five|six|seven|eight|nine|ten', Comment = 'This is a ''|'' separated list of cardinal numbers from one to ten. This is used to find mentions of quantities of certain items.';
        RestrictedWordsTxt: Label 'and|for|the', Comment = 'Words that should not be used to find items in the item table (separated by "|").';
        TelemetryClosedPageTxt: Label 'Suggested line items closed via %2 action.%1  Items Suggested: %3%1  Items selected: %4', Locked = true;
        TelemetryAlgorithmPerformanceTxt: Label 'Item generation algorithm finished in %2ms.%1  Length of mail body: %3%1  Total items found:   %4%1  Single item matches: %5%1  Total matched items: %6', Locked = true;

    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Office Document Handler", 'OnGenerateLinesFromText', '', false, false)]
    local procedure CreateLinesBasedOnQuantityReferences(var HeaderRecRef: RecordRef; var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; EmailBody: Text)
    var
        OfficeMgt: Codeunit "Office Management";
        Match: DotNet Match;
        Matches: DotNet MatchCollection;
        Regex: DotNet Regex;
        Stopwatch: DotNet Stopwatch;
        SanitizedBody: Text;
        QuantityText: Text;
        FoundPre: Integer;
        PreItemNo: Text[50];
        PreText: Text;
        StrengthPre: Decimal;
        FoundPost: Integer;
        PostItemNo: Text[50];
        PostText: Text;
        StrengthPost: Decimal;
        Quantity: Integer;
        SingleMatches: Integer;
        TotalMatches: Integer;
        AlreadyFound: Boolean;
        IsHandled: Boolean;
    begin
        // Searches for quantity keywords (1,2,..,999,one,two,..,nine) and takes the first four
        // words on either side of the keyword as the "pretext" and the "posttext". Using the
        // pretext and posttext values, we try to take substrings and find matches in the Item
        // table. If multiple matches are found, then we allow the user to resolve the item.
        IsHandled := false;
        OnBeforeCreateLinesBasedOnQuantityReferences(HeaderRecRef, TempOfficeSuggestedLineItem, EmailBody, IsHandled);
        if IsHandled then
            exit;

        // Measure the time it takes to find the items
        Stopwatch := Stopwatch.StartNew();

        // Find the words on each side of the quantity key word
        // Use negative lookahead to find overlapping matches
        Regex := Regex.Regex(StrSubstNo('((\w+ ?){0,4}\s+(?!>=(\d{1,3}|%1)))(?=(\d{1,3}|%1)\s+?((\w+ ?){0,4}))', NumbersTxt));
        Matches := Regex.Matches(EmailBody);

        // Get rid of insignificant words
        SanitizedBody := SanitizeText(EmailBody, 2);

        foreach Match in Matches do begin
            PreItemNo := '';
            PostItemNo := '';
            FoundPre := 0;

            PreText := SanitizeText(Match.Groups.Item(1).Value, 3);
            PostText := SanitizeText(Match.Groups.Item(5).Value, 3);
            QuantityText := Match.Groups.Item(4).Value();

            FoundPre := FindItemFromText(PreItemNo, PreText, SanitizedBody, ' ', true);
            AlreadyFound := ItemAlreadyFound(TempOfficeSuggestedLineItem, PreItemNo);
            StrengthPre := CalculateMatchStrength(PreItemNo, FoundPre, PreText, AlreadyFound);

            FoundPost := FindItemFromText(PostItemNo, PostText, SanitizedBody, ' ', true);
            AlreadyFound := ItemAlreadyFound(TempOfficeSuggestedLineItem, PostItemNo);
            StrengthPost := CalculateMatchStrength(PostItemNo, FoundPost, PostText, AlreadyFound);

            ConvertToInteger(Quantity, QuantityText);
            if (StrengthPre > 0) and (StrengthPre > StrengthPost) then
                InsertSuggestedLineItem(TempOfficeSuggestedLineItem, PreItemNo, PreText, Quantity, FoundPre)
            else
                if StrengthPost > 0 then
                    InsertSuggestedLineItem(TempOfficeSuggestedLineItem, PostItemNo, PostText, Quantity, FoundPost)
        end;

        Stopwatch.Stop();
        GetMatchTotals(TempOfficeSuggestedLineItem, SingleMatches, TotalMatches);
        Session.LogMessage('00001KH', StrSubstNo(TelemetryAlgorithmPerformanceTxt, NewLine(),
            Stopwatch.ElapsedMilliseconds,
            StrLen(EmailBody),
            TempOfficeSuggestedLineItem.Count,
            SingleMatches,
            TotalMatches), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', OfficeMgt.GetOfficeAddinTelemetryCategory());
    end;

    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Office Document Handler", 'OnGenerateLinesFromText', '', false, false)]
    local procedure CreateLinesBasedOnItemReferences(var HeaderRecRef: RecordRef; var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; EmailBody: Text)
    var
        OfficeMgt: Codeunit "Office Management";
        Match: DotNet Match;
        Regex: DotNet Regex;
        Stopwatch: DotNet Stopwatch;
        Word: DotNet String;
        WordMatches: DotNet MatchCollection;
        SanitizedBody: Text;
        ItemNo: Text[50];
        BestItem: Text[50];
        BestText: Text;
        BestCount: Integer;
        BestStrength: Decimal;
        SingleMatches: Integer;
        TotalMatches: Integer;
        IsHandled: Boolean;
    begin
        IsHandled := false;
        OnBeforeCreateLinesBasedOnItemReferences(HeaderRecRef, TempOfficeSuggestedLineItem, EmailBody, IsHandled);
        if IsHandled then
            exit;

        if not TempOfficeSuggestedLineItem.IsEmpty() then
            exit;

        Stopwatch := Stopwatch.StartNew();

        SanitizedBody := SanitizeText(EmailBody, 2);
        Regex := Regex.Regex('([\w\d]{3,})');
        WordMatches := Regex.Matches(SanitizedBody);

        foreach Match in WordMatches do begin
            ItemNo := '';
            BestCount := 999999;
            BestStrength := 0;
            BestText := '';
            BestItem := '';
            Word := StrSubstNo('%1', Match.Value);
            if PerformSearch(ItemNo, Word, BestCount, BestText, BestItem, BestStrength, SanitizedBody, true) then
                InsertSuggestedLineItem(TempOfficeSuggestedLineItem, ItemNo, Match.Value, 0, BestCount);
        end;

        Stopwatch.Stop();
        GetMatchTotals(TempOfficeSuggestedLineItem, SingleMatches, TotalMatches);
        Session.LogMessage('00001KI', StrSubstNo(TelemetryAlgorithmPerformanceTxt, NewLine(),
            Stopwatch.ElapsedMilliseconds,
            StrLen(EmailBody),
            TempOfficeSuggestedLineItem.Count,
            SingleMatches,
            TotalMatches), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', OfficeMgt.GetOfficeAddinTelemetryCategory());
    end;

    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Office Document Handler", 'OnCloseSuggestedLineItemsPage', '', false, false)]
    local procedure CreateLineItemsOnCloseSuggestedLineItems(var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; var HeaderRecRef: RecordRef; PageCloseAction: Action)
    var
        OfficeMgt: Codeunit "Office Management";
        AddedCount: Integer;
    begin
        if PageCloseAction in [ACTION::OK, ACTION::LookupOK] then
            if TempOfficeSuggestedLineItem.FindSet() then
                repeat
                    if TempOfficeSuggestedLineItem.Add then begin
                        InsertLineItem(HeaderRecRef, TempOfficeSuggestedLineItem."Item No.", TempOfficeSuggestedLineItem.Quantity);
                        AddedCount += 1;
                    end;
                until TempOfficeSuggestedLineItem.Next() = 0;

        Session.LogMessage('00001KJ', StrSubstNo(TelemetryClosedPageTxt, NewLine(),
            PageCloseAction,
            TempOfficeSuggestedLineItem.Count,
            AddedCount), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', OfficeMgt.GetOfficeAddinTelemetryCategory());
    end;

    local procedure CalculateMatchStrength(ItemNo: Text[50]; Matches: Integer; SearchText: Text; AlreadyFound: Boolean) Strength: Decimal
    var
        Item: Record Item;
    begin
        // Determines the strength of the matched item or items based on the length
        // of the search text that was used to find those items and how close the search text
        // is to the actual description of the item.

        case true of
            Matches = 0:
                Strength := 0;
            (Matches = 1) and AlreadyFound:
                Strength := 0.01;
            (Matches = 1) and Item.Get(ItemNo):
                Strength := 1 + ((StrLen(SearchText) / StrLen(Item.Description)) * (StrLen(SearchText) / StrLen(Item.Description)));
            Matches > 1:
                Strength := 1 + (1 / Matches) - (1 / (1 + StrLen(SearchText)));
        end;

        if AlreadyFound then
            Strength *= (1 - (1 / (Matches + 1))); // Strength is weakened if the item has already been found. More so if there are fewer matches.
    end;

    local procedure ConvertToInteger(var NumberValue: Integer; NumberText: Text)
    var
        NumbersList: DotNet IList;
        NumbersString: DotNet String;
        Separator: DotNet String;
    begin
        if Evaluate(NumberValue, NumberText) then
            exit;

        NumbersString := NumbersTxt;
        Separator := '|';
        NumbersList := NumbersString.Split(Separator.ToCharArray());
        NumberValue := NumbersList.IndexOf(NumberText) + 1;
    end;

    local procedure FindItemFromText(var ItemNo: Text[50]; var SearchText: Text; EmailBody: Text; Separator: Text; Recurse: Boolean): Integer
    var
        TermsArray: DotNet Array;
        SearchTerms: DotNet String;
        SeparatorString: DotNet String;
        i: Integer;
        "Count": Integer;
        BestCount: Integer;
        BestText: Text;
        BestItem: Text[50];
        BestStrength: Decimal;
    begin
        if StrLen(SearchText) < 3 then
            exit(0);

        SeparatorString := Separator;
        SearchTerms := DelChr(SearchText, '<>', ' ');
        TermsArray := SearchTerms.Split(SeparatorString.ToCharArray());
        Count := TermsArray.Length;

        BestCount := 999999; // Sentinel value

        PerformSearch(ItemNo, SearchText, BestCount, BestText, BestItem, BestStrength, EmailBody, Recurse);

        // Try to resolve the item. The best search is the search that yields the lowest, non-zero number of results.
        for i := 1 to Count - 1 do begin
            // Try with the last (n - i) words of the search phrase
            SearchText := OmitFirst(TermsArray, i, Separator);
            PerformSearch(ItemNo, SearchText, BestCount, BestText, BestItem, BestStrength, EmailBody, Recurse);

            // Try with the first (n - i) words of the search phrase
            SearchText := OmitLast(TermsArray, i, Separator);
            PerformSearch(ItemNo, SearchText, BestCount, BestText, BestItem, BestStrength, EmailBody, Recurse);

            // Try with the ith word of the search phrase
            SearchText := TermsArray.GetValue(i);
            PerformSearch(ItemNo, SearchText, BestCount, BestText, BestItem, BestStrength, EmailBody, Recurse);
        end;

        if BestCount < 999999 then begin
            SearchText := BestText;
            ItemNo := BestItem;
            exit(BestCount);
        end;
    end;

    local procedure FindItemsByDescription(var ItemNo: Text; Description: Text) FoundCount: Integer
    var
        Item: Record Item;
        SearchText: Text;
    begin
        // Searches for items by the specified description.
        // (1) Look for the item no.
        // (2) Look for an exact match on the description
        // (3) Look for items that start with the description
        // (4) Look for items that include the description

        Item.SetRange("No.", CopyStr(Description, 1, 20));
        if not Item.IsEmpty() then begin
            FoundCount := Item.Count();
            Item.FindFirst();
            ItemNo := Item."No.";
            exit;
        end;
        Item.SetRange("No.");

        SearchText := '''@' + Description + '''';
        Item.SetFilter(Description, Description);
        if not Item.IsEmpty() then begin
            FoundCount := Item.Count();
            Item.FindFirst();
            ItemNo := Item."No.";
            exit;
        end;

        SearchText := '''@' + Description + '*''';
        Item.SetFilter(Description, SearchText);
        if not Item.IsEmpty() then begin
            FoundCount := Item.Count();
            Item.FindFirst();
            ItemNo := Item."No.";
            exit;
        end;

        SearchText := '''@* ' + Description + '*''';
        Item.SetFilter(Description, SearchText);
        if not Item.IsEmpty() then begin
            FoundCount := Item.Count();
            Item.FindFirst();
            ItemNo := Item."No.";
            exit;
        end;
    end;

    local procedure GetMatchTotals(var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; var SingleMatches: Integer; var TotalMatches: Integer)
    begin
        TempOfficeSuggestedLineItem.SetRange(Matches, 1);
        SingleMatches := TempOfficeSuggestedLineItem.Count();
        TempOfficeSuggestedLineItem.SetRange(Matches);

        if TempOfficeSuggestedLineItem.FindSet() then
            repeat
                TotalMatches += TempOfficeSuggestedLineItem.Matches;
            until TempOfficeSuggestedLineItem.Next() = 0;
    end;

    local procedure ItemAlreadyFound(var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; ItemNo: Text) Found: Boolean
    begin
        TempOfficeSuggestedLineItem.SetRange("Item No.", ItemNo);
        Found := not TempOfficeSuggestedLineItem.IsEmpty();
        TempOfficeSuggestedLineItem.SetRange("Item No.");
    end;

    local procedure InsertSuggestedLineItem(var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; ItemNo: Text[50]; SearchKeyword: Text; Quantity: Integer; Matches: Integer)
    var
        LastLineNo: Integer;
    begin
        if (Matches = 0) or (SearchKeyword = '') then
            exit;

        if not TempOfficeSuggestedLineItem.IsEmpty() then
            TempOfficeSuggestedLineItem.FindLast();

        LastLineNo := TempOfficeSuggestedLineItem."Line No.";

        if Matches > 1 then
            TempOfficeSuggestedLineItem.SetRange("Item Description", CopyStr(SearchKeyword, 1, 50))
        else
            TempOfficeSuggestedLineItem.SetRange("Item No.", ItemNo);

        if TempOfficeSuggestedLineItem.FindFirst() then begin
            if (TempOfficeSuggestedLineItem.Quantity = 0) and (Quantity > 0) then
                TempOfficeSuggestedLineItem.Validate(Quantity, Quantity);
            TempOfficeSuggestedLineItem.Modify(true);
        end else begin
            TempOfficeSuggestedLineItem.Init();
            TempOfficeSuggestedLineItem.Validate("Line No.", LastLineNo + 1000);
            if Matches = 1 then
                TempOfficeSuggestedLineItem.Validate("Item No.", CopyStr(ItemNo, 1, 20))
            else
                TempOfficeSuggestedLineItem.Validate("Item Description", CopyStr(SearchKeyword, 1, 50));
            TempOfficeSuggestedLineItem.Validate(Quantity, Quantity);
            TempOfficeSuggestedLineItem.Validate(Matches, Matches);
            TempOfficeSuggestedLineItem.Insert(true);
        end;

        TempOfficeSuggestedLineItem.SetRange("Item No.");
        TempOfficeSuggestedLineItem.SetRange("Item Description");
    end;

    local procedure InsertLineItem(var HeaderRecRef: RecordRef; ItemNo: Text[50]; Quantity: Integer)
    var
        SalesHeader: Record "Sales Header";
        PurchaseHeader: Record "Purchase Header";
    begin
        case HeaderRecRef.Number of
            DATABASE::"Sales Header":
                begin
                    HeaderRecRef.SetTable(SalesHeader);
                    InsertSalesLine(SalesHeader, ItemNo, Quantity);
                end;
            DATABASE::"Purchase Header":
                begin
                    HeaderRecRef.SetTable(PurchaseHeader);
                    InsertPurchaseLine(PurchaseHeader, ItemNo, Quantity);
                end;
        end;
    end;

    local procedure InsertSalesLine(var SalesHeader: Record "Sales Header"; ItemNo: Text[50]; Quantity: Integer)
    var
        SalesLine: Record "Sales Line";
        LineNo: Integer;
    begin
        LineNo := 10000;
        SalesLine.SetRange("Document Type", SalesHeader."Document Type");
        SalesLine.SetRange("Document No.", SalesHeader."No.");
        if SalesLine.FindLast() then
            LineNo := SalesLine."Line No." + 10000;

        SalesLine.Init();
        SalesLine.Validate("Document Type", SalesHeader."Document Type");
        SalesLine.Validate("Document No.", SalesHeader."No.");
        SalesLine.Validate("Line No.", LineNo);
        SalesLine.Validate("Sell-to Customer No.", SalesHeader."Sell-to Customer No.");
        SalesLine.Validate(Type, SalesLine.Type::Item);
        SalesLine.Validate("No.", CopyStr(ItemNo, 1, 20));
        SalesLine.Validate(Quantity, Quantity);
        SalesLine.Insert(true);
        Commit();
    end;

    local procedure InsertPurchaseLine(var PurchaseHeader: Record "Purchase Header"; ItemNo: Text[50]; Quantity: Integer)
    var
        PurchaseLine: Record "Purchase Line";
        LineNo: Integer;
    begin
        LineNo := 10000;
        PurchaseLine.SetRange("Document Type", PurchaseHeader."Document Type");
        PurchaseLine.SetRange("Document No.", PurchaseHeader."No.");
        if PurchaseLine.FindLast() then
            LineNo := PurchaseLine."Line No." + 10000;

        PurchaseLine.Init();
        PurchaseLine.Validate("Document Type", PurchaseHeader."Document Type");
        PurchaseLine.Validate("Document No.", PurchaseHeader."No.");
        PurchaseLine.Validate("Line No.", LineNo);
        PurchaseLine.Validate("Buy-from Vendor No.", PurchaseHeader."Buy-from Vendor No.");
        PurchaseLine.Validate(Type, PurchaseLine.Type::Item);
        PurchaseLine.Validate("No.", CopyStr(ItemNo, 1, 20));
        PurchaseLine.Validate(Quantity, Quantity);
        PurchaseLine.Insert(true);
        Commit();
    end;

    local procedure NewLine() CrLf: Text[2]
    begin
        CrLf[1] := 13;
        CrLf[2] := 10;
    end;

    local procedure OmitFirst(SearchTerms: DotNet Array; i: Integer; Separator: Text) Result: Text
    var
        TempResult: Text;
        j: Integer;
    begin
        for j := i to SearchTerms.Length - 1 do begin
            Evaluate(TempResult, SearchTerms.GetValue(j));
            Result += TempResult + Separator;
        end;
        Result := DelChr(Result, '>', Separator);
    end;

    local procedure OmitLast(SearchTerms: DotNet Array; i: Integer; Separator: Text) Result: Text
    var
        TempResult: Text;
        j: Integer;
    begin
        for j := 0 to SearchTerms.Length - 1 - i do begin
            Evaluate(TempResult, SearchTerms.GetValue(j));
            Result += TempResult + Separator;
        end;
        Result := DelChr(Result, '>', Separator);
    end;

    local procedure PerformSearch(var ItemNo: Text[50]; var SearchText: Text; var BestCount: Integer; var BestText: Text; var BestItem: Text[50]; var BestStrength: Decimal; EmailBody: Text; Recurse: Boolean): Boolean
    var
        FoundCount: Integer;
        Strength: Decimal;
    begin
        FoundCount := FindItemsByDescription(ItemNo, SearchText);
        Strength := CalculateMatchStrength(ItemNo, FoundCount, SearchText, false);
        if (FoundCount > 0) and (Strength > BestStrength) then begin
            BestCount := FoundCount;
            BestStrength := Strength;
            BestText := SearchText;
            BestItem := ItemNo;
        end;
        if Recurse and (FoundCount > 1) then begin
            FoundCount := ResolveItem(ItemNo, SearchText, EmailBody);
            Strength := CalculateMatchStrength(ItemNo, FoundCount, SearchText, false);
            if (FoundCount > 0) and (Strength > BestStrength) then begin
                BestStrength := Strength;
                BestCount := FoundCount;
                BestText := SearchText;
                BestItem := ItemNo;
            end;
        end;

        exit(FoundCount > 0);
    end;

    local procedure ResolveItem(var ItemNo: Text[50]; var Description: Text; EmailBody: Text): Integer
    var
        PreRegex: DotNet Regex;
        PreMatches: DotNet MatchCollection;
        PostRegex: DotNet Regex;
        PostMatches: DotNet MatchCollection;
        Match: DotNet Match;
        NewDescription: DotNet String;
        FoundCount: Integer;
        BestCount: Integer;
        BestDescription: Text;
        OriginalDescription: Text;
        Strength: Decimal;
        BestStrength: Decimal;
    begin
        if StrLen(Description) < 4 then
            exit(0);

        OriginalDescription := Description;
        FoundCount := FindItemsByDescription(ItemNo, Description);
        if FoundCount = 1 then
            exit(FoundCount);

        BestCount := 999999; // sentinel value

        // Find all phrases around Description - up to 2 words on left side
        PreRegex := PreRegex.Regex(StrSubstNo('([^\W\d]+ ?){0,2} ?\b%1\b', Description));
        PreMatches := PreRegex.Matches(EmailBody);
        foreach Match in PreMatches do begin
            NewDescription := PreRegex.Replace(SanitizeText(Match.Value, 3), ' ', '*');
            NewDescription := NewDescription.Replace(ConvertStr(OriginalDescription, ' ', '*'), OriginalDescription);
            if Format(NewDescription) <> OriginalDescription then begin
                FoundCount := FindItemFromText(ItemNo, NewDescription, EmailBody, '*', false);
                Strength := CalculateMatchStrength(ItemNo, FoundCount, NewDescription, false);
                if (FoundCount > 0) and (Strength > BestStrength) then begin
                    BestStrength := Strength;
                    BestDescription := NewDescription;
                end;
            end;
        end;

        // Find all phrases around Description - up to 2 words on right side
        PostRegex := PostRegex.Regex(StrSubstNo('\b%1\b ?([^\W\d]+ ?){0,2}', Description));
        PostMatches := PostRegex.Matches(EmailBody);
        foreach Match in PostMatches do begin
            NewDescription := PostRegex.Replace(SanitizeText(Match.Value, 3), ' ', '*');
            NewDescription := NewDescription.Replace(ConvertStr(OriginalDescription, ' ', '*'), OriginalDescription);
            if Format(NewDescription) <> OriginalDescription then begin
                FoundCount := FindItemFromText(ItemNo, NewDescription, EmailBody, '*', false);
                Strength := CalculateMatchStrength(ItemNo, FoundCount, NewDescription, false);
                if (FoundCount > 0) and (Strength > BestStrength) then begin
                    BestCount := FoundCount;
                    BestDescription := NewDescription;
                end;
            end;
        end;

        if (BestCount < 999999) and (StrPos(BestDescription, ConvertStr(OriginalDescription, ' ', '*')) > 0) then begin
            Description := BestDescription;
            if BestCount = 1 then
                FindItemFromText(ItemNo, BestDescription, EmailBody, '*', false);
            exit(BestCount);
        end;
    end;

    local procedure SanitizeText(Text: Text; MinWordLength: Integer) Sanitized: Text
    var
        CultureInfo: DotNet CultureInfo;
        PluralizationService: DotNet PluralizationService;
        Regex: DotNet Regex;
        WordMatches: DotNet MatchCollection;
        Match: DotNet Match;
        Word: Text;
        Restricted: Text;
    begin
        if Text = '' then
            exit;

        // Sanitization:
        // 1. Trim white space
        // 2. Remove any words < specified minimum word lengthh
        // 3. Remove any restricted words (the, for, but, etc.)
        // 4. Singularize each word

        Sanitized := Text;

        Sanitized := Regex.Replace(Sanitized, StrSubstNo('\b\w{1,%1}\b\s?', MinWordLength - 1), '');
        Sanitized := Regex.Replace(Sanitized, '\b\d{1,3}\b', '');
        Restricted := '\b' + Regex.Replace(RestrictedWordsTxt, '\|', '\b|\b') + '\b'; // Make sure to match whole words only
        Sanitized := Regex.Replace(Sanitized, Restricted, '');
        Sanitized := Regex.Replace(Sanitized, '^\s*', ''); // Trim white space
        Sanitized := Regex.Replace(Sanitized, '\s*$', ''); // Trim white space
        Sanitized := Regex.Replace(Sanitized, '[^\s]{51,}', ''); // Remove words longer than 50 characters

        // The pluralization service only supports English
        PluralizationService := PluralizationService.CreateService(CultureInfo.CultureInfo('en'));
        WordMatches := Regex.Matches(Sanitized, '\b(\w+)\b');
        foreach Match in WordMatches do begin
            Word := Match.Groups.Item(1).Value();
            if PluralizationService.IsPlural(Word) then
                Sanitized := Regex.Replace(Sanitized, Word, PluralizationService.Singularize(Word));
        end;
        Sanitized := PluralizationService.Singularize(Sanitized);
    end;

    [IntegrationEvent(false, false)]
    local procedure OnBeforeCreateLinesBasedOnQuantityReferences(var HeaderRecRef: RecordRef; var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; EmailBody: Text; var IsHandled: Boolean)
    begin
    end;

    [IntegrationEvent(false, false)]
    local procedure OnBeforeCreateLinesBasedOnItemReferences(var HeaderRecRef: RecordRef; var TempOfficeSuggestedLineItem: Record "Office Suggested Line Item" temporary; EmailBody: Text; var IsHandled: Boolean)
    begin
    end;
}