diff --git a/src/Apps/W1/External Storage - Document Attachments/app/ExtensionLogo.png b/src/Apps/W1/External Storage - Document Attachments/app/ExtensionLogo.png
new file mode 100644
index 0000000000..4d2c9a626c
Binary files /dev/null and b/src/Apps/W1/External Storage - Document Attachments/app/ExtensionLogo.png differ
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/README.md b/src/Apps/W1/External Storage - Document Attachments/app/README.md
new file mode 100644
index 0000000000..ea283d21b0
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/README.md
@@ -0,0 +1,159 @@
+# Document Attachments External Storage for Microsoft Dynamics 365 Business Central
+
+## Overview
+
+The External Storage extension provides seamless integration between Microsoft Dynamics 365 Business Central and external storage systems such as Azure Blob Storage, SharePoint, and File Shares. This extension automatically manages document attachments by storing them in external storage systems while maintaining full functionality within Business Central.
+
+## Key Features
+
+### **Multi-Tenant, Multi-Environment, and Multi-Company Support**
+- **Environment Hash**: Unique hash based on Tenant ID + Environment Name + Company System ID
+- **Organized Folder Structure**: Files are stored in hierarchical folders: `RootFolder/EnvironmentHash/TableName/FileName`
+- **Cross-Environment Compatibility**: Files from different tenants, environments, or companies are properly isolated
+- **Migration Support**: Built-in migration tool to move files between company folders when needed
+- **Environment Hash Display**: View current environment hash for reference and troubleshooting
+
+### **Flexible Deletion Policies**
+- **Delete from External Storage**: Optionally delete files from external storage when attachments are removed from BC
+- **Automatic Cleanup**: Scheduled job queue can automatically delete expired files based on retention policy
+
+### **Customizable Root Folder**
+- Configure a custom root folder path for all attachments
+- Interactive folder browser for easy selection
+- Automatic folder creation and hierarchy management
+
+### **Bulk Operations**
+- Synchronize multiple files between internal and external storage
+- Bulk upload to external storage
+- Bulk download from external storage
+- Progress tracking with detailed reporting
+
+## Installation & Setup
+
+### Prerequisites
+- Microsoft Dynamics 365 Business Central version 28.0 or later
+- File Account module configured with external storage connector
+- Appropriate permissions for file operations
+
+### Installation Steps
+
+1. **Configure File Account**
+ - Open **File Accounts** page
+ - Create a new File Account with your preferred connector:
+ - Azure Blob Storage
+ - SharePoint
+ - File Share
+ - Assign the account to **External Storage** scenario
+
+2. **Configure External Storage**
+ - Open **File Accounts** page
+ - Select assigned **External Storage** scenario
+ - Open **Additional Scenario Setup**
+ - Configure settings:
+ - **Enabled**: Enable the External Storage feature
+ - **Root Folder**: Select the root folder path for attachments (use AssistEdit to browse)
+ - **Delete from External Storage**: Enable to delete external files when attachments are removed from BC
+
+### Configuration Options
+
+#### General Settings
+- **Enabled**: Master switch to activate/deactivate the External Storage feature
+- **Root Folder**: Base folder path in external storage where all attachments will be organized
+ - Files are stored in: `RootFolder/EnvironmentHash/TableName/FileName`
+ - Use AssistEdit button to browse and select folder interactively
+
+#### Upload and Delete Policy
+- **Delete from External Storage**: When enabled, files are deleted from external storage when the attachment is removed from Business Central
+
+
+## Usage
+
+### Multi-Company and Multi-Environment Support
+
+#### Environment Hash
+Every file uploaded to external storage includes an environment hash that uniquely identifies:
+- **Tenant ID**: Your Business Central tenant
+- **Environment Name**: Current environment (e.g., Production, Sandbox)
+- **Company System ID**: Unique identifier for the company
+
+This ensures files from different tenants, environments, or companies are properly isolated in external storage.
+
+#### Folder Structure
+Files are organized hierarchically:
+```
+RootFolder/
+ ├── [EnvironmentHash-1]/
+ │ ├── Sales_Header/
+ │ │ └── invoice-{guid}.pdf
+ │ └── Purchase_Header/
+ │ └── order-{guid}.pdf
+ └── [EnvironmentHash-2]/
+ └── Sales_Header/
+ └── quote-{guid}.pdf
+```
+
+#### File Migration
+When moving data between environments or companies:
+1. Open **External Storage Setup** page
+2. Click **Migrate Files** action
+3. System automatically:
+ - Identifies files from previous environment/company
+ - Copies files to current environment/company folder structure
+ - Updates file paths and environment hash
+ - Maintains all file metadata and associations
+
+#### Environment Hash Display
+- Click **Show Current Environment Hash** to view your current hash
+- Use this hash to identify your files in external storage
+- Helpful for troubleshooting and cross-environment scenarios
+
+### Manual Operations
+
+#### Setup Page Actions
+From **External Storage Setup** page:
+- **Storage Sync**: Run synchronization manually to upload/download files
+- **Migrate Files**: Migrate all files from previous environment/company to current folder structure
+- **Show Current Environment Hash**: Display the current environment hash for reference
+- **Document Attachments**: Open the list of all document attachments with external storage information
+
+#### Individual File Operations
+From **Document Attachment - External** page:
+- **Upload to External Storage**: Upload selected file manually
+- **Download from External Storage**: Download file for viewing
+- **Download to Internal Storage**: Restore file to internal storage
+- **Delete from External Storage**: Remove file from external storage
+- **Delete from Internal Storage**: Remove file from internal storage only
+
+#### Bulk Operations
+From **External Storage Synchronize** report:
+- **To External Storage**: Upload multiple files to external storage
+- **From External Storage**: Download multiple files from external storage
+- **Delete Expired Files**: Clean up files based on retention policy
+
+### File Access and Compatibility
+- Files uploaded to external storage remain fully accessible through standard Business Central functionality
+- Document preview, download, and management work seamlessly
+- Files deleted internally are automatically retrieved from external storage when accessed
+- No change to end-user experience
+- Cross-environment and cross-company access is handled automatically
+
+## Important Notes
+
+### Data Safety
+- **This extension is provided as-is**
+- Always maintain proper backups of your external storage
+- Test thoroughly in a sandbox environment before production use
+- Verify file accessibility after migration
+
+### Environment Changes
+- When moving between environments, use the **Migrate Files** action
+- Environment hash changes with tenant, environment, or company changes
+- Files from previous environments are not automatically deleted
+- Manual cleanup of old environment folders may be required
+
+### Feature Disable Protection
+- Cannot disable External Storage setup if files are uploaded
+- Must delete all uploaded files before disabling the feature
+- Cannot unassign External Storage scenario if files exist in external storage
+
+**© 2025 Microsoft Corporation. All rights reserved.**
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/app.json b/src/Apps/W1/External Storage - Document Attachments/app/app.json
new file mode 100644
index 0000000000..ad78fde90a
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/app.json
@@ -0,0 +1,35 @@
+{
+ "id": "5f2e93a0-6083-4718-b05a-7ac89be5644d",
+ "name": "External Storage - Document Attachments",
+ "publisher": "Microsoft",
+ "version": "28.0.0.0",
+ "brief": "External Storage processor for Business Central document attachments",
+ "description": "Processes document attachments from Business Central and stores them in configured External Storage connectors (Azure Blob, File Share, SharePoint)",
+ "privacyStatement": "https://go.microsoft.com/fwlink/?linkid=724009",
+ "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120",
+ "help": "https://go.microsoft.com/fwlink/?linkid=2134520",
+ "url": "https://go.microsoft.com/fwlink/?linkid=724011",
+ "logo": "ExtensionLogo.png",
+ "application": "28.0.0.0",
+ "platform": "28.0.0.0",
+ "internalsVisibleTo": [
+ ],
+ "dependencies": [],
+ "screenshots": [],
+ "idRanges": [
+ {
+ "from": 8750,
+ "to": 8770
+ }
+ ],
+ "resourceExposurePolicy": {
+ "allowDebugging": true,
+ "allowDownloadingSource": true,
+ "includeSourceInSymbolFile": true
+ },
+ "features": [
+ "TranslationFile"
+ ],
+ "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2134520",
+ "target": "Cloud"
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/AutomaticSync/DAExternalStorageMigration.Report.al b/src/Apps/W1/External Storage - Document Attachments/app/src/AutomaticSync/DAExternalStorageMigration.Report.al
new file mode 100644
index 0000000000..b2de62ab2d
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/AutomaticSync/DAExternalStorageMigration.Report.al
@@ -0,0 +1,124 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using Microsoft.Foundation.Attachment;
+
+///
+/// Report for migrating document attachments from previous environment/company folder to current environment/company folder.
+/// Can be scheduled via job queue for background processing.
+///
+report 8753 "DA External Storage Migration"
+{
+ Caption = 'External Storage Migration';
+ ProcessingOnly = true;
+ UseRequestPage = true;
+ Extensible = false;
+ ApplicationArea = Basic, Suite;
+ UsageCategory = None;
+ Permissions = tabledata "DA External Storage Setup" = r,
+ tabledata "Document Attachment" = rimd;
+
+ dataset
+ {
+ dataitem(DocumentAttachment; "Document Attachment")
+ {
+ trigger OnPreDataItem()
+ begin
+ // Filter for files that are uploaded externally and need migration
+ SetRange("Uploaded Externally", true);
+ SetFilter("External File Path", '<>%1', '');
+
+ TotalCount := Count();
+
+ if TotalCount = 0 then begin
+ if GuiAllowed() then
+ Message(NoFilesToMigrateLbl);
+ CurrReport.Break();
+ end;
+
+ ProcessedCount := 0;
+ MigratedCount := 0;
+ FailedCount := 0;
+
+ if GuiAllowed() then
+ Dialog.Open(ProcessingMsg, TotalCount);
+ end;
+
+ trigger OnAfterGetRecord()
+ begin
+ ProcessedCount += 1;
+
+ if GuiAllowed() then
+ Dialog.Update(1, ProcessedCount);
+
+ // Check if file needs migration
+ if ExternalStorageImpl.IsFileFromAnotherEnvironmentOrCompany(DocumentAttachment) then
+ if ExternalStorageImpl.MigrateFileToCurrentEnvironment(DocumentAttachment) then
+ MigratedCount += 1
+ else
+ FailedCount += 1;
+
+ Commit(); // Commit after each record to avoid lost in communication error with external storage service
+ end;
+
+ trigger OnPostDataItem()
+ begin
+ if MigratedCount > 0 then
+ LogMigrationTelemetry();
+
+ if GuiAllowed() then begin
+ if TotalCount <> 0 then
+ Dialog.Close();
+
+ if MigratedCount > 0 then
+ Message(MigrationCompletedMsg, MigratedCount, FailedCount)
+ else
+ Message(NoFilesToMigrateLbl);
+ end;
+ end;
+ }
+ }
+
+ requestpage
+ {
+ SaveValues = true;
+
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ Caption = 'General';
+ label(InfoLabel)
+ {
+ ApplicationArea = Basic, Suite;
+ Caption = 'This report will migrate all document attachments from a previous environment or company folder to the current environment/company folder in external storage.';
+ }
+ }
+ }
+ }
+ }
+
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ Dialog: Dialog;
+ FailedCount: Integer;
+ MigratedCount: Integer;
+ ProcessedCount: Integer;
+ TotalCount: Integer;
+ MigrationCompletedMsg: Label '%1 file(s) have been successfully migrated to the current company folder. %2 failed.', Comment = '%1 = Number of migrated files, %2 = Number of failed migrations';
+ NoFilesToMigrateLbl: Label 'No files need to be migrated.';
+ ProcessingMsg: Label 'Processing #1###### attachments...', Comment = '%1 - Total Number of Attachments';
+
+ local procedure LogMigrationTelemetry()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ DAFeatureTelemetry.LogCompanyMigration();
+ end;
+}
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/AutomaticSync/DAExternalStorageSync.Report.al b/src/Apps/W1/External Storage - Document Attachments/app/src/AutomaticSync/DAExternalStorageSync.Report.al
new file mode 100644
index 0000000000..5deeb11ce2
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/AutomaticSync/DAExternalStorageSync.Report.al
@@ -0,0 +1,149 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using Microsoft.Foundation.Attachment;
+
+///
+/// Report for synchronizing document attachments between internal and external storage.
+/// Supports bulk upload, download, and cleanup operations.
+///
+report 8752 "DA External Storage Sync"
+{
+ Caption = 'External Storage Synchronization';
+ ProcessingOnly = true;
+ UseRequestPage = true;
+ Extensible = false;
+ ApplicationArea = Basic, Suite;
+ UsageCategory = None;
+ Permissions = tabledata "DA External Storage Setup" = r,
+ tabledata "Document Attachment" = r;
+
+ dataset
+ {
+ dataitem(DocumentAttachment; "Document Attachment")
+ {
+ trigger OnPreDataItem()
+ begin
+ SetFilters();
+ TotalCount := Count();
+
+ if TotalCount = 0 then begin
+ if GuiAllowed() then
+ Message(NoRecordsMsg);
+ CurrReport.Break();
+ end;
+
+ if MaxRecordsToProcess > 0 then
+ if TotalCount > MaxRecordsToProcess then
+ TotalCount := MaxRecordsToProcess;
+
+ ProcessedCount := 0;
+ FailedCount := 0;
+
+ if GuiAllowed() then
+ Dialog.Open(ProcessingMsg, TotalCount);
+ end;
+
+ trigger OnAfterGetRecord()
+ begin
+ ProcessedCount += 1;
+
+ if GuiAllowed() then
+ Dialog.Update(1, ProcessedCount);
+
+ case SyncDirection of
+ SyncDirection::"To External Storage":
+ if not ExternalStorageImpl.UploadToExternalStorage(DocumentAttachment) then
+ FailedCount += 1;
+ SyncDirection::"From External Storage":
+
+ if not ExternalStorageImpl.DownloadFromExternalStorage(DocumentAttachment) then
+ FailedCount += 1;
+ end;
+ Commit(); // Commit after each record to avoid lost in communication error with external storage service
+
+ if (MaxRecordsToProcess > 0) and (ProcessedCount >= MaxRecordsToProcess) then
+ CurrReport.Break();
+ end;
+
+ trigger OnPostDataItem()
+ begin
+ LogSyncTelemetry();
+
+ if GuiAllowed() then begin
+ if TotalCount <> 0 then
+ Dialog.Close();
+ Message(ProcessedMsg, ProcessedCount - FailedCount, FailedCount);
+ end;
+ end;
+ }
+ }
+
+ requestpage
+ {
+ SaveValues = true;
+
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ Caption = 'General';
+ field(SyncDirectionField; SyncDirection)
+ {
+ ApplicationArea = Basic, Suite;
+ Caption = 'Sync Direction';
+ OptionCaption = 'To External Storage,From External Storage';
+ ToolTip = 'Specifies whether to sync to external storage or from external storage.';
+ }
+ field(MaxRecordsToProcessField; MaxRecordsToProcess)
+ {
+ ApplicationArea = Basic, Suite;
+ Enabled = SyncDirection = SyncDirection::"To External Storage";
+ Caption = 'Maximum Records to Process';
+ ToolTip = 'Specifies the maximum number of records to process in one run. Leave 0 for unlimited.';
+ MinValue = 0;
+ }
+ }
+ }
+ }
+ }
+
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ Dialog: Dialog;
+ FailedCount: Integer;
+ MaxRecordsToProcess: Integer;
+ ProcessedCount: Integer;
+ TotalCount: Integer;
+ NoRecordsMsg: Label 'No records found to process.';
+ ProcessedMsg: Label 'Processed %1 attachments successfully. %2 failed.', Comment = '%1 - Number of Processed Attachments, %2 - Number of Failed Attachments';
+ ProcessingMsg: Label 'Processing #1###### attachments...', Comment = '%1 - Total Number of Attachments';
+ SyncDirection: Option "To External Storage","From External Storage";
+
+ local procedure SetFilters()
+ begin
+ case SyncDirection of
+ SyncDirection::"To External Storage":
+ DocumentAttachment.SetRange("Uploaded Externally", false);
+ SyncDirection::"From External Storage":
+ DocumentAttachment.SetRange("Uploaded Externally", true);
+ end;
+ end;
+
+ local procedure LogSyncTelemetry()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ // Log manual sync when run from UI (GuiAllowed), auto sync when run from job queue
+ if GuiAllowed() then
+ DAFeatureTelemetry.LogManualSync()
+ else
+ DAFeatureTelemetry.LogAutoSync();
+ end;
+}
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DAExtStorageFileScenario.EnumExt.al b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DAExtStorageFileScenario.EnumExt.al
new file mode 100644
index 0000000000..837531dbbd
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DAExtStorageFileScenario.EnumExt.al
@@ -0,0 +1,21 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using System.ExternalFileStorage;
+
+///
+/// Extends File Scenario enum with External Storage option.
+/// Allows File Account framework to recognize external storage scenarios.
+///
+enumextension 8750 "DA Ext. Storage-File Scenario" extends "File Scenario"
+{
+ value(8750; "Doc. Attach. - External Storage")
+ {
+ Caption = 'Document Attachments - External Storage';
+ Implementation = "File Scenario" = "DA External Storage Impl.";
+ }
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DAExternalStorageImpl.Codeunit.al b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DAExternalStorageImpl.Codeunit.al
new file mode 100644
index 0000000000..20f25867d9
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DAExternalStorageImpl.Codeunit.al
@@ -0,0 +1,948 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using Microsoft.Foundation.Attachment;
+using System.Environment;
+using System.ExternalFileStorage;
+using System.Security.Encryption;
+using System.Utilities;
+
+codeunit 8751 "DA External Storage Impl." implements "File Scenario"
+{
+ Access = Internal;
+ InherentPermissions = X;
+ InherentEntitlements = X;
+ Permissions = tabledata "Tenant Media" = rimd,
+ tabledata "Document Attachment" = rimd,
+ tabledata "File Account" = r,
+ tabledata "DA External Storage Setup" = r;
+
+ #region File Scenario Interface Implementation
+ ///
+ /// Called before adding or modifying a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if the operation is allowed, otherwise false.
+ procedure BeforeAddOrModifyFileScenarioCheck(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SkipInsertOrModify: Boolean;
+ var
+ FileAccount: Record "File Account";
+ ConfirmManagement: Codeunit "Confirm Management";
+ FileScenarioCU: Codeunit "File Scenario";
+ DisclaimerMsg: Label 'You are about to enable External Storage.\\When this feature is enabled, files will be stored outside the Business Central service boundary.\Microsoft does not manage, back up, or restore data stored in external storage.\\You are responsible for the configuration, security, compliance, backup, and recovery of all externally stored files.\This feature is provided as-is, and you enable it at your own risk.\\Do you want to continue?';
+ begin
+ if not (Scenario = Enum::"File Scenario"::"Doc. Attach. - External Storage") then
+ exit;
+
+ // Search for External Storage assigned File Scenario
+ if FileScenarioCU.GetSpecificFileAccount(Scenario, FileAccount) then begin
+ SkipInsertOrModify := true;
+ exit;
+ end;
+
+ SkipInsertOrModify := not ConfirmManagement.GetResponseOrDefault(DisclaimerMsg);
+ end;
+
+ ///
+ /// Called to get additional setup for a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if additional setup is available, otherwise false.
+ procedure GetAdditionalScenarioSetup(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SetupExist: Boolean;
+ var
+ ExternalStorageSetup: Page "DA External Storage Setup";
+ begin
+ if not (Scenario = Enum::"File Scenario"::"Doc. Attach. - External Storage") then
+ exit;
+
+ ExternalStorageSetup.RunModal();
+ SetupExist := true;
+ end;
+
+ ///
+ /// Called before deleting a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if the delete operation is handled and should not proceed, otherwise false.
+ procedure BeforeDeleteFileScenarioCheck(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SkipDelete: Boolean;
+ var
+ ExternalStorageSetup: Record "DA External Storage Setup";
+ NotPossibleToUnassignScenarioMsg: Label 'External Storage scenario can not be unassigned when there are uploaded files.';
+ begin
+ if not (Scenario = Enum::"File Scenario"::"Doc. Attach. - External Storage") then
+ exit;
+
+ if not ExternalStorageSetup.Get() then
+ exit;
+
+ ExternalStorageSetup.CalcFields("Has Uploaded Files");
+ if not ExternalStorageSetup."Has Uploaded Files" then
+ exit;
+
+ SkipDelete := true;
+ Message(NotPossibleToUnassignScenarioMsg);
+ end;
+ #endregion
+
+ ///
+ /// Provides functionality to manage document attachments in external storage systems.
+ /// Handles upload, download, and deletion operations for Business Central attachments.
+ ///
+ #region External Storage Operations
+ ///
+ /// Uploads a document attachment to external storage.
+ ///
+ /// The document attachment record to upload.
+ /// True if upload was successful, false otherwise.
+ procedure UploadToExternalStorage(var DocumentAttachment: Record "Document Attachment"): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ TempBlob: Codeunit "Temp Blob";
+ FileScenario: Enum "File Scenario";
+ InStream: InStream;
+ OutStream: OutStream;
+ FileName: Text[2048];
+ begin
+ // Check if feature is enabled
+ if not IsFeatureEnabled() then
+ exit(false);
+
+ // Validate input parameters
+ if not DocumentAttachment."Document Reference ID".HasValue() then
+ exit(false);
+
+ // Check if document is already uploaded
+ if DocumentAttachment."External File Path" <> '' then
+ exit(false);
+
+ // Telemetry logging for feature usage
+ LogFeatureUsedTelemetry();
+
+ // Get file content from document attachment
+ TempBlob.CreateOutStream(OutStream);
+ DocumentAttachment.ExportToStream(OutStream);
+ TempBlob.CreateInStream(InStream);
+
+ // Generate unique filename to prevent collisions
+ FileName := GetFilePathWithRootFolder(DocumentAttachment);
+
+ // Search for External Storage assigned File Scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ // Create the file with connector using the File Account framework
+ ExternalFileStorage.Initialize(FileScenario);
+ if ExternalFileStorage.CreateFile(FileName, InStream) then begin
+ DocumentAttachment."Uploaded Externally" := true;
+ DocumentAttachment."External Upload Date" := CurrentDateTime();
+ DocumentAttachment."External File Path" := FileName;
+ DocumentAttachment."Source Environment Hash" := GetCurrentEnvironmentHash();
+ DocumentAttachment.Modify();
+ LogFileUploadedTelemetry();
+ exit(true);
+ end;
+
+ exit(false);
+ end;
+
+ ///
+ /// Downloads a document attachment from external storage and prompts user to save it locally.
+ ///
+ /// The document attachment record to download.
+ /// True if download was successful, false otherwise.
+ procedure DownloadFromExternalStorage(var DocumentAttachment: Record "Document Attachment"): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ FileScenario: Enum "File Scenario";
+ InStream: InStream;
+ ExternalFilePath, FileName : Text;
+ begin
+ // Check if feature is enabled
+ if not IsFeatureEnabled() then
+ exit(false);
+
+ // Validate input parameters
+ if DocumentAttachment."External File Path" = '' then
+ exit(false);
+
+ if not DocumentAttachment."Uploaded Externally" then
+ exit(false);
+
+ // Use the stored external file path
+ ExternalFilePath := DocumentAttachment."External File Path";
+ FileName := DocumentAttachment."File Name" + '.' + DocumentAttachment."File Extension";
+
+ // Search for External Storage assigned File Scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ // Get the file with connector using the File Account framework
+ ExternalFileStorage.Initialize(FileScenario);
+ ExternalFileStorage.GetFile(ExternalFilePath, InStream);
+
+ if DownloadFromStream(InStream, '', '', '', FileName) then begin
+ LogFileDownloadedTelemetry();
+ exit(true);
+ end;
+
+ exit(false);
+ end;
+
+ ///
+ /// Downloads a document attachment from external storage and saves it to internal storage.
+ ///
+ /// The document attachment record to download and restore internally.
+ /// True if download and import was successful, false otherwise.
+ procedure DownloadFromExternalStorageToInternal(var DocumentAttachment: Record "Document Attachment"): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ FileScenario: Enum "File Scenario";
+ InStream: InStream;
+ ExternalFilePath, FileName : Text;
+ begin
+ // Validate input parameters
+ if DocumentAttachment."External File Path" = '' then
+ exit(false);
+
+ if not DocumentAttachment."Uploaded Externally" then
+ exit(false);
+
+ // Use the stored external file path
+ ExternalFilePath := DocumentAttachment."External File Path";
+ FileName := DocumentAttachment."File Name" + '.' + DocumentAttachment."File Extension";
+
+ // Search for External Storage assigned File Scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ // Get the file with connector using the File Account framework
+ ExternalFileStorage.Initialize(FileScenario);
+ ExternalFileStorage.GetFile(ExternalFilePath, InStream);
+
+ // Import the file into the Document Attachment
+ DocumentAttachment.ImportAttachment(InStream, FileName);
+ DocumentAttachment."Deleted Internally" := false;
+ DocumentAttachment.Modify();
+
+ exit(true);
+ end;
+
+ ///
+ /// Downloads a document attachment from external storage to a stream.
+ ///
+ /// The path of the external file to download.
+ /// The output stream to write the attachment to.
+ /// True if the download was successful, false otherwise.
+ procedure DownloadFromExternalStorageToStream(ExternalFilePath: Text; var AttachmentOutStream: OutStream): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ FileScenario: Enum "File Scenario";
+ InStream: InStream;
+ begin
+ // Search for External Storage assigned File Scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ // Get the file from external storage
+ ExternalFileStorage.Initialize(FileScenario);
+ if not ExternalFileStorage.GetFile(ExternalFilePath, InStream) then
+ exit(false);
+
+ // Copy to output stream
+ CopyStream(AttachmentOutStream, InStream);
+ exit(true);
+ end;
+
+ ///
+ /// Downloads a document attachment from external storage to a Temp Blob.
+ ///
+ /// The path of the external file to download.
+ /// The temporary blob to store the downloaded content.
+ /// True if the download was successful, false otherwise.
+ procedure DownloadFromExternalStorageToTempBlob(ExternalFilePath: Text; var TempBlob: Codeunit "Temp Blob"): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ FileScenario: Enum "File Scenario";
+ InStream: InStream;
+ OutStream: OutStream;
+ begin
+ // Search for External Storage assigned File Scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ // Get the file from external storage
+ ExternalFileStorage.Initialize(FileScenario);
+ if not ExternalFileStorage.GetFile(ExternalFilePath, InStream) then
+ exit(false);
+
+ // Copy to TempBlob
+ TempBlob.CreateOutStream(OutStream);
+ CopyStream(OutStream, InStream);
+ exit(true);
+ end;
+
+ ///
+ /// Checks if a file exists in external storage.
+ ///
+ /// The path of the external file to check.
+ /// True if the file exists, false otherwise.
+ procedure CheckIfFileExistInExternalStorage(ExternalFilePath: Text): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ FileScenario: Enum "File Scenario";
+ begin
+ // Search for External Storage assigned File Scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ // Get the file from external storage
+ ExternalFileStorage.Initialize(FileScenario);
+ exit(ExternalFileStorage.FileExists(ExternalFilePath));
+ end;
+
+ ///
+ /// Deletes a document attachment from external storage.
+ ///
+ /// The document attachment record to delete from external storage.
+ /// True if deletion was successful, false otherwise.
+ procedure DeleteFromExternalStorage(var DocumentAttachment: Record "Document Attachment"): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ FileScenario: Enum "File Scenario";
+ ExternalFilePath: Text;
+ begin
+ // Check if feature is enabled
+ if not IsFeatureEnabled() then
+ exit(false);
+
+ if not DocumentAttachment.Find() then
+ exit(false);
+
+ // Validate input parameters
+ if DocumentAttachment."External File Path" = '' then
+ exit(false);
+
+ if not DocumentAttachment."Uploaded Externally" then
+ exit(false);
+
+ if DocumentAttachment."Skip Delete On Copy" then
+ exit(false);
+
+ // Check if file belongs to another environment - if so, just clear the reference
+ if IsFileFromAnotherEnvironmentOrCompany(DocumentAttachment) then begin
+ DocumentAttachment.MarkAsNotUploadedToExternal();
+ exit(true);
+ end;
+
+ // Use the stored external file path
+ ExternalFilePath := DocumentAttachment."External File Path";
+
+ // Search for External Storage assigned File Scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ // Delete the file with connector using the File Account framework
+ ExternalFileStorage.Initialize(FileScenario);
+ if ExternalFileStorage.DeleteFile(ExternalFilePath) then begin
+ DocumentAttachment.MarkAsNotUploadedToExternal();
+ LogFileDeletedTelemetry();
+ exit(true);
+ end;
+
+ exit(false);
+ end;
+
+ ///
+ /// Deletes a document attachment from internal storage.
+ ///
+ /// The document attachment record to delete from internal storage.
+ /// True if deletion was successful, false otherwise.
+ procedure DeleteFromInternalStorage(var DocumentAttachment: Record "Document Attachment"): Boolean
+ var
+ TenantMedia: Record "Tenant Media";
+ begin
+ // Validate input parameters
+ if not DocumentAttachment."Document Reference ID".HasValue() then
+ exit(false);
+
+ // Check if file is uploaded externally before deleting internally
+ if not DocumentAttachment."Uploaded Externally" then
+ exit(false);
+
+ // Delete from Tenant Media
+ if TenantMedia.Get(DocumentAttachment."Document Reference ID".MediaId()) then begin
+ TenantMedia.Delete();
+
+ // Mark Document Attachment as Deleted Internally
+ DocumentAttachment.MarkAsDeletedInternally();
+ exit(true);
+ end;
+
+ exit(false);
+ end;
+
+ ///
+ /// Maps file extensions to their corresponding MIME types.
+ ///
+ /// The document attachment record.
+ /// The content type to set based on the file extension.
+ procedure FileExtensionToContentMimeType(var Rec: Record "Document Attachment"; var ContentType: Text[100])
+ begin
+ // Determine content type based on file extension
+ case LowerCase(Rec."File Extension") of
+ 'pdf':
+ ContentType := 'application/pdf';
+ 'jpg', 'jpeg':
+ ContentType := 'image/jpeg';
+ 'png':
+ ContentType := 'image/png';
+ 'gif':
+ ContentType := 'image/gif';
+ 'bmp':
+ ContentType := 'image/bmp';
+ 'tiff', 'tif':
+ ContentType := 'image/tiff';
+ 'doc':
+ ContentType := 'application/msword';
+ 'docx':
+ ContentType := 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+ 'xls':
+ ContentType := 'application/vnd.ms-excel';
+ 'xlsx':
+ ContentType := 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+ 'ppt':
+ ContentType := 'application/vnd.ms-powerpoint';
+ 'pptx':
+ ContentType := 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
+ 'txt':
+ ContentType := 'text/plain';
+ 'xml':
+ ContentType := 'text/xml';
+ 'html', 'htm':
+ ContentType := 'text/html';
+ 'zip':
+ ContentType := 'application/zip';
+ 'rar':
+ ContentType := 'application/x-rar-compressed';
+ else
+ ContentType := 'application/octet-stream';
+ end;
+ end;
+
+ ///
+ /// Checks if a Document Attachment file is uploaded to external storage and deleted internally.
+ ///
+ /// The Document Attachment record to check.
+ /// True if the file is uploaded and deleted, false otherwise.
+ procedure IsFileUploadedToExternalStorageAndDeletedInternally(var DocumentAttachment: Record "Document Attachment"): Boolean
+ begin
+ if not DocumentAttachment."Deleted Internally" then
+ exit(false);
+
+ if not DocumentAttachment."Uploaded Externally" then
+ exit(false);
+
+ if DocumentAttachment."Document Reference ID".HasValue() then
+ exit(false);
+
+ if DocumentAttachment."External File Path" = '' then
+ exit(false);
+ exit(true);
+ end;
+ #endregion
+
+ #region Document Attachment Handling
+ ///
+ /// Handles automatic upload of new document attachments to external storage upon insertion of the attachment record.
+ ///
+ /// The document attachment record.
+ /// Indicates if the trigger should run.
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnAfterInsertEvent, '', true, true)]
+ local procedure OnAfterInsertDocumentAttachment(var Rec: Record "Document Attachment"; RunTrigger: Boolean)
+ var
+ ExternalStorageSetup: Record "DA External Storage Setup";
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ // Exit early if trigger is not running
+ if not RunTrigger then
+ exit;
+
+ // Temporary records are not processed
+ if Rec.IsTemporary() then
+ exit;
+
+ // Check if auto upload is enabled
+ if not ExternalStorageSetup.Get() then
+ exit;
+
+ // Only process files with actual content
+ if not Rec."Document Reference ID".HasValue() then
+ exit;
+
+ // Upload to external storage
+ if not ExternalStorageImpl.UploadToExternalStorage(Rec) then
+ exit;
+
+ ExternalStorageImpl.DeleteFromInternalStorage(Rec);
+ end;
+
+ ///
+ /// Handles automatic deletion of document attachments from external storage upon deletion of the attachment record.
+ ///
+ /// The document attachment record.
+ /// Indicates if the trigger should run.
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnAfterDeleteEvent, '', true, true)]
+ local procedure OnAfterDeleteDocumentAttachment(var Rec: Record "Document Attachment"; RunTrigger: Boolean)
+ var
+ ExternalStorageSetup: Record "DA External Storage Setup";
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ // Exit early if trigger is not running
+ if not RunTrigger then
+ exit;
+
+ // Temporary records are not processed
+ if Rec.IsTemporary() then
+ exit;
+
+ // Check if auto upload is enabled
+ if not ExternalStorageSetup.Get() then
+ exit;
+
+ if not ExternalStorageSetup."Delete from External Storage" then
+ exit;
+
+ // Only process files that were uploaded to external storage
+ if not Rec."Uploaded Externally" then
+ exit;
+
+ // Delete from external storage
+ ExternalStorageImpl.DeleteFromExternalStorage(Rec);
+
+ if Rec."Skip Delete On Copy" then begin
+ Rec."Skip Delete On Copy" := false;
+ Rec.Modify();
+ end;
+ end;
+
+ ///
+ /// Handles exporting document attachment content to a stream for externally stored attachments.
+ ///
+ /// The document attachment record.
+ /// The output stream for the attachment content.
+ /// Indicates if the event has been handled.
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnBeforeExportToStream, '', false, false)]
+ local procedure DocumentAttachment_OnBeforeExportToStream(var DocumentAttachment: Record "Document Attachment"; var AttachmentOutStream: OutStream; var IsHandled: Boolean)
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ // Only handle if file is uploaded externally and not available internally
+ if not ExternalStorageImpl.IsFileUploadedToExternalStorageAndDeletedInternally(DocumentAttachment) then
+ exit;
+
+ ExternalStorageImpl.DownloadFromExternalStorageToStream(DocumentAttachment."External File Path", AttachmentOutStream);
+ IsHandled := true;
+ end;
+
+ ///
+ /// Handles getting the temporary blob for externally stored document attachments.
+ ///
+ /// The document attachment record.
+ /// The temporary blob to be filled.
+ /// Indicates if the event has been handled.
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnBeforeGetAsTempBlob, '', false, false)]
+ local procedure DocumentAttachment_OnBeforeGetAsTempBlob(var DocumentAttachment: Record "Document Attachment"; var TempBlob: Codeunit "Temp Blob"; var IsHandled: Boolean)
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ // Only handle if file is uploaded externally and not available internally
+ if not ExternalStorageImpl.IsFileUploadedToExternalStorageAndDeletedInternally(DocumentAttachment) then
+ exit;
+
+ ExternalStorageImpl.DownloadFromExternalStorageToTempBlob(DocumentAttachment."External File Path", TempBlob);
+ IsHandled := true;
+ end;
+
+ ///
+ /// Handles getting content type for externally stored document attachments.
+ ///
+ /// The document attachment record.
+ /// The content type to be set.
+ /// Indicates if the event has been handled.
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnBeforeGetContentType, '', false, false)]
+ local procedure DocumentAttachment_OnBeforeGetContentType(var Rec: Record "Document Attachment"; var ContentType: Text[100]; var IsHandled: Boolean)
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ // Only handle if file is uploaded externally and not available internally
+ if not ExternalStorageImpl.IsFileUploadedToExternalStorageAndDeletedInternally(Rec) then
+ exit;
+
+ ExternalStorageImpl.FileExtensionToContentMimeType(Rec, ContentType);
+ IsHandled := true;
+ end;
+
+ ///
+ /// Handles checking if attachment content is available for externally stored document attachments.
+ ///
+ /// The document attachment record.
+ /// Indicates if the attachment is available.
+ /// Indicates if the event has been handled.
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnBeforeHasContent, '', false, false)]
+ local procedure DocumentAttachment_OnBeforeHasContent(var DocumentAttachment: Record "Document Attachment"; var AttachmentIsAvailable: Boolean; var IsHandled: Boolean)
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ // Only handle if file is uploaded externally and not available internally
+ if not ExternalStorageImpl.IsFileUploadedToExternalStorageAndDeletedInternally(DocumentAttachment) then
+ exit;
+
+ AttachmentIsAvailable := ExternalStorageImpl.CheckIfFileExistInExternalStorage(DocumentAttachment."External File Path");
+ IsHandled := true;
+ end;
+
+ ///
+ /// Event handler for before checking Document Reference ID on insert.
+ ///
+ /// The document attachment record.
+ /// Indicates if the event has been handled.
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnInsertOnBeforeCheckDocRefID, '', false, false)]
+ local procedure "Document Attachment_OnInsertOnBeforeCheckDocRefID"(var DocumentAttachment: Record "Document Attachment"; var IsHandled: Boolean)
+ begin
+ if DocumentAttachment."Uploaded Externally" then
+ IsHandled := true;
+ end;
+
+ [EventSubscriber(ObjectType::Codeunit, Codeunit::"Document Attachment Mgmt", OnCopyAttachmentsOnAfterSetToDocumentFilters, '', false, false)]
+ local procedure "Document Attachment Mgmt_OnCopyAttachmentsOnAfterSetToDocumentFilters"(var ToDocumentAttachment: Record "Document Attachment"; ToRecRef: RecordRef; ToAttachmentDocumentType: Enum "Attachment Document Type"; ToNo: Code[20]; ToLineNo: Integer)
+ begin
+ ToDocumentAttachment."Skip Delete On Copy" := ToDocumentAttachment."Uploaded Externally";
+ end;
+
+ [EventSubscriber(ObjectType::Table, Database::"Document Attachment", OnBeforeOpenInOneDrive, '', false, false)]
+ local procedure "Document Attachment_OnBeforeOpenInOneDrive"(var Rec: Record "Document Attachment"; var IsHandled: Boolean)
+ var
+ NotSupportedMsg: Label 'Opening Document Attachments stored in External Storage via OneDrive is not supported.';
+ begin
+ if Rec."Uploaded Externally" then begin
+ IsHandled := true;
+ Message(NotSupportedMsg);
+ end;
+ end;
+ #endregion
+
+ ///
+ /// Opens a folder selection dialog for choosing the root folder.
+ ///
+ /// The selected folder path, or empty string if cancelled.
+ procedure SelectRootFolder(): Text
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ SelectFolderPathLbl: Label 'Select Root Folder for Attachments';
+ FileScenario: Enum "File Scenario";
+ begin
+ // Initialize external file storage with the scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit('');
+
+ ExternalFileStorage.Initialize(FileScenario);
+ exit(ExternalFileStorage.SelectAndGetFolderPath('', SelectFolderPathLbl));
+ end;
+
+ local procedure GetFilePathWithRootFolder(DocumentAttachment: Record "Document Attachment"): Text[2048]
+ var
+ ExternalStorageSetup: Record "DA External Storage Setup";
+ FileName: Text;
+ RootFolder: Text;
+ TableNameFolder: Text[100];
+ EnvironmentHashFolder: Text[16];
+ FileNamePart: Text;
+ begin
+ // Generate unique filename to prevent collisions
+ FileNamePart := DocumentAttachment."File Name" + '-' + DelChr(Format(CreateGuid()), '=', '{}') + '.' + DocumentAttachment."File Extension";
+
+ // Get table name folder (based on the source table of the attachment)
+ TableNameFolder := GetTableNameFolder(DocumentAttachment."Table ID");
+
+ // Get environment hash folder (based on tenant + environment + company)
+ EnvironmentHashFolder := GetCurrentEnvironmentHash();
+
+ // Get root folder from setup if configured
+ if not ExternalStorageSetup.Get() then
+ exit;
+
+ RootFolder := ExternalStorageSetup."Root Folder";
+ if RootFolder <> '' then begin
+ // Ensure root folder ends with a separator
+ if not RootFolder.EndsWith('/') and not RootFolder.EndsWith('\') then
+ RootFolder := RootFolder + '/';
+
+ // Ensure environment hash folder exists
+ EnsureFolderExists(RootFolder + EnvironmentHashFolder);
+
+ // Ensure environment hash folder exists within table folder
+ EnsureFolderExists(RootFolder + EnvironmentHashFolder + '/' + TableNameFolder);
+
+ FileName := RootFolder + EnvironmentHashFolder + '/' + TableNameFolder + '/' + FileNamePart;
+ end else begin
+ // No root folder, add environment folder at root level
+ EnsureFolderExists(EnvironmentHashFolder);
+
+ // Ensure environment hash folder exists within table folder
+ EnsureFolderExists(EnvironmentHashFolder + '/' + TableNameFolder);
+
+ FileName := EnvironmentHashFolder + '/' + TableNameFolder + '/' + FileNamePart;
+ end;
+
+ exit(CopyStr(FileName, 1, 2048));
+ end;
+
+ local procedure GetTableNameFolder(TableID: Integer): Text[100]
+ var
+ TableName: Text;
+ begin
+ // Try to get table name from metadata, fallback to table ID if not available
+ if not TryGetTableName(TableID, TableName) then
+ TableName := 'Table_' + Format(TableID);
+
+ // Replace invalid characters for folder names
+ TableName := DelChr(TableName, '=', '<>:"/\|?*');
+ TableName := ConvertStr(TableName, ' ', '_');
+
+ exit(CopyStr(TableName, 1, 100));
+ end;
+
+ [TryFunction]
+ local procedure TryGetTableName(TableID: Integer; var TableName: Text)
+ var
+ RecRef: RecordRef;
+ begin
+ // Open the RecordRef to get table metadata
+ // This will fail if the table no longer exists (e.g., after uninstalling a 3rd party app)
+ RecRef.Open(TableID, false);
+ TableName := RecRef.Name;
+ RecRef.Close();
+ end;
+
+ local procedure EnsureFolderExists(CompanyFolderPath: Text)
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ FileScenario: Enum "File Scenario";
+ begin
+ // Initialize external file storage with the scenario
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit;
+
+ ExternalFileStorage.Initialize(FileScenario);
+
+ // Check if directory exists, if not create it
+ if not ExternalFileStorage.DirectoryExists(CompanyFolderPath) then
+ ExternalFileStorage.CreateDirectory(CompanyFolderPath);
+ end;
+
+ ///
+ /// Gets the current environment hash for use in folder structure.
+ ///
+ /// The hash value (first 16 characters of SHA256).
+ procedure GetCurrentEnvironmentHash(): Text[16]
+ var
+ Company: Record Company;
+ EnvironmentInformation: Codeunit "Environment Information";
+ CryptographyManagement: Codeunit "Cryptography Management";
+ HashAlgorithmType: Option MD5,SHA1,SHA256,SHA384,SHA512;
+ IdentityString: Text;
+ begin
+ Company.Get(CompanyName());
+
+ // Combine Tenant ID + Environment Name + Company System ID
+ IdentityString := TenantId() + '|' + EnvironmentInformation.GetEnvironmentName() + '|' + Format(Company.SystemId);
+
+ // Generate SHA256 hash and take first 16 characters
+ exit(CopyStr(CryptographyManagement.GenerateHash(IdentityString, HashAlgorithmType::SHA256), 1, 16));
+ end;
+
+ ///
+ /// Checks if a file belongs to another environment or company.
+ ///
+ /// The document attachment record to check.
+ /// True if the file is from another environment or company, false otherwise.
+ procedure IsFileFromAnotherEnvironmentOrCompany(DocumentAttachment: Record "Document Attachment"): Boolean
+ var
+ CurrentEnvironmentHash: Text[16];
+ begin
+ // If no source environment hash is set, assume it belongs to current environment
+ if DocumentAttachment."Source Environment Hash" = '' then
+ exit(false);
+
+ CurrentEnvironmentHash := GetCurrentEnvironmentHash();
+ exit(DocumentAttachment."Source Environment Hash" <> CurrentEnvironmentHash);
+ end;
+
+ ///
+ /// Migrates a file from a previous environment or company folder to the current one.
+ ///
+ /// The document attachment record to migrate.
+ /// True if migration was successful, false otherwise.
+ procedure MigrateFileToCurrentEnvironment(var DocumentAttachment: Record "Document Attachment"): Boolean
+ var
+ FileAccount: Record "File Account";
+ ExternalFileStorage: Codeunit "External File Storage";
+ FileScenarioCU: Codeunit "File Scenario";
+ TempBlob: Codeunit "Temp Blob";
+ FileScenario: Enum "File Scenario";
+ InStream: InStream;
+ OutStream: OutStream;
+ OldFilePath: Text;
+ NewFilePath: Text[2048];
+ begin
+ if not DocumentAttachment."Uploaded Externally" then
+ exit(false);
+
+ if DocumentAttachment."External File Path" = '' then
+ exit(false);
+
+ // Initialize external file storage
+ FileScenario := FileScenario::"Doc. Attach. - External Storage";
+ if not FileScenarioCU.GetSpecificFileAccount(FileScenario, FileAccount) then
+ exit(false);
+
+ ExternalFileStorage.Initialize(FileScenario);
+
+ // Download file from old location
+ OldFilePath := DocumentAttachment."External File Path";
+ if not ExternalFileStorage.GetFile(OldFilePath, InStream) then
+ exit(false);
+
+ // Copy to TempBlob
+ TempBlob.CreateOutStream(OutStream);
+ CopyStream(OutStream, InStream);
+ TempBlob.CreateInStream(InStream);
+
+ // Generate new file path in current company folder
+ NewFilePath := GetFilePathWithRootFolder(DocumentAttachment);
+
+ // Upload to new location
+ if not ExternalFileStorage.CreateFile(NewFilePath, InStream) then
+ exit(false);
+
+ // Update document attachment record
+ DocumentAttachment."External File Path" := NewFilePath;
+ DocumentAttachment."Source Environment Hash" := GetCurrentEnvironmentHash();
+ DocumentAttachment."External Upload Date" := CurrentDateTime();
+ DocumentAttachment.Modify();
+
+ exit(true);
+ end;
+
+ ///
+ /// Runs migration for all document attachments from a previous company.
+ ///
+ /// Number of files migrated.
+ procedure RunCompanyMigration(): Integer
+ var
+ DocumentAttachment: Record "Document Attachment";
+ MigratedCount: Integer;
+ StartMigrationQst: Label 'This will migrate all document attachments from the previous company folder to the current company folder.\\Do you want to continue?';
+ MigrationCompletedMsg: Label '%1 file(s) have been successfully migrated to the current company folder.', Comment = '%1 = Number of files';
+ begin
+ if not Confirm(StartMigrationQst, false) then
+ exit(0);
+
+ MigratedCount := 0;
+ DocumentAttachment.SetRange("Uploaded Externally", true);
+ DocumentAttachment.SetFilter("External File Path", '<>%1', '');
+ if DocumentAttachment.FindSet(true) then
+ repeat
+ if IsFileFromAnotherEnvironmentOrCompany(DocumentAttachment) then
+ if MigrateFileToCurrentEnvironment(DocumentAttachment) then
+ MigratedCount += 1;
+ until DocumentAttachment.Next() = 0;
+
+ if MigratedCount > 0 then begin
+ LogCompanyMigrationTelemetry();
+ Message(MigrationCompletedMsg, MigratedCount);
+ end;
+
+ exit(MigratedCount);
+ end;
+
+ #region Telemetry Logging
+ local procedure LogFeatureUsedTelemetry()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ DAFeatureTelemetry.LogFeatureUsed();
+ end;
+
+ local procedure LogFileUploadedTelemetry()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ DAFeatureTelemetry.LogFileUploaded();
+ end;
+
+ local procedure LogFileDownloadedTelemetry()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ DAFeatureTelemetry.LogFileDownloaded();
+ end;
+
+ local procedure LogFileDeletedTelemetry()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ DAFeatureTelemetry.LogFileDeleted();
+ end;
+
+ local procedure LogCompanyMigrationTelemetry()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ DAFeatureTelemetry.LogCompanyMigration();
+ end;
+
+ local procedure IsFeatureEnabled(): Boolean
+ var
+ ExternalStorageSetup: Record "DA External Storage Setup";
+ begin
+ if not ExternalStorageSetup.Get() then
+ exit(false);
+
+ exit(ExternalStorageSetup.Enabled);
+ end;
+ #endregion
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DocumentAttachmentExtStor.TableExt.al b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DocumentAttachmentExtStor.TableExt.al
new file mode 100644
index 0000000000..5778adbcec
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DocumentAttachmentExtStor.TableExt.al
@@ -0,0 +1,84 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using Microsoft.Foundation.Attachment;
+
+///
+/// Extends the Document Attachment table with external storage functionality.
+/// Adds fields and procedures to track attachments in external storage systems.
+///
+tableextension 8750 "Document Attachment Ext.Stor." extends "Document Attachment"
+{
+ fields
+ {
+ field(8750; "Uploaded Externally"; Boolean)
+ {
+ Caption = 'Uploaded Externally';
+ DataClassification = SystemMetadata;
+ Editable = false;
+ ToolTip = 'Specifies if the file has been uploaded to external storage.';
+ }
+ field(8751; "External Upload Date"; DateTime)
+ {
+ Caption = 'External Upload Date';
+ DataClassification = SystemMetadata;
+ Editable = false;
+ ToolTip = 'Specifies when the file was uploaded to external storage.';
+ }
+ field(8752; "External File Path"; Text[2048])
+ {
+ Caption = 'External File Path';
+ DataClassification = CustomerContent;
+ Editable = false;
+ ToolTip = 'Specifies the path to the file in external storage.';
+ }
+ field(8753; "Deleted Internally"; Boolean)
+ {
+ Caption = 'Deleted Internally';
+ DataClassification = SystemMetadata;
+ Editable = false;
+ ToolTip = 'Specifies if the file has been deleted from internal storage.';
+ }
+ field(8754; "Source Environment Hash"; Text[16])
+ {
+ Caption = 'Source Environment Hash';
+ DataClassification = SystemMetadata;
+ Editable = false;
+ ToolTip = 'Specifies a hash identifying the tenant, environment, and company that originally uploaded this file to external storage.';
+ }
+ field(8755; "Skip Delete On Copy"; Boolean)
+ {
+ Caption = 'Skip Delete On Copy';
+ DataClassification = SystemMetadata;
+ Editable = false;
+ ToolTip = 'Specifies whether to skip deletion of this attachment from external storage.';
+ }
+ }
+
+ ///
+ /// Marks the document attachment as not uploaded to external storage.
+ /// Clears all external storage related fields.
+ ///
+ internal procedure MarkAsNotUploadedToExternal()
+ begin
+ "Uploaded Externally" := false;
+ "External Upload Date" := 0DT;
+ "External File Path" := '';
+ Modify();
+ end;
+
+ ///
+ /// Marks the document attachment as deleted from internal storage.
+ /// Clears the Document Reference ID and sets the deleted internally flag.
+ ///
+ internal procedure MarkAsDeletedInternally()
+ begin
+ Clear("Document Reference ID");
+ "Deleted Internally" := true;
+ Modify();
+ end;
+}
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DocumentAttachmentExternal.Page.al b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DocumentAttachmentExternal.Page.al
new file mode 100644
index 0000000000..d2d149d1e2
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/DocumentAttachmentIntegration/DocumentAttachmentExternal.Page.al
@@ -0,0 +1,203 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using Microsoft.Foundation.Attachment;
+
+///
+/// List page for managing document attachments with external storage information.
+/// Provides actions for upload, download, and deletion operations.
+///
+page 8751 "Document Attachment - External"
+{
+ PageType = List;
+ SourceTable = "Document Attachment";
+ Caption = 'Document Attachments - External Storage';
+ UsageCategory = None;
+ ApplicationArea = Basic, Suite;
+ Editable = false;
+ Extensible = false;
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(General)
+ {
+ field("Table ID"; Rec."Table ID")
+ {
+ ToolTip = 'Specifies the table ID the attachment belongs to.';
+ }
+ field("No."; Rec."No.")
+ {
+ ToolTip = 'Specifies the record number the attachment belongs to.';
+ }
+ field("File Name"; Rec."File Name")
+ {
+ ToolTip = 'Specifies the name of the attached file.';
+ }
+ field("File Extension"; Rec."File Extension")
+ {
+ ToolTip = 'Specifies the file extension of the attached file.';
+ }
+ field("Attached Date"; Rec."Attached Date")
+ {
+ ToolTip = 'Specifies the date the file was attached.';
+ }
+ field("Attached By"; Rec."Attached By")
+ {
+ ToolTip = 'Specifies the user who attached the file.';
+ }
+ field("Deleted Internally"; Rec."Deleted Internally")
+ {
+ }
+ field("Uploaded to External"; Rec."Uploaded Externally")
+ {
+ Caption = 'Uploaded to External';
+ }
+ field("External Upload Date"; Rec."External Upload Date")
+ {
+ Caption = 'Upload Date';
+ }
+ field("External File Path"; Rec."External File Path")
+ {
+ Caption = 'External File Path';
+ }
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action("Upload to External")
+ {
+ Enabled = not Rec."Uploaded Externally";
+ Caption = 'Upload to External';
+ ToolTip = 'Upload the selected file to external storage.';
+ Image = Export;
+
+ trigger OnAction()
+ var
+ DocumentAttachment: Record "Document Attachment";
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ CurrPage.SetSelectionFilter(DocumentAttachment);
+ if DocumentAttachment.FindSet() then
+ repeat
+ if ExternalStorageImpl.UploadToExternalStorage(DocumentAttachment) then
+ Message(FileUploadedMsg)
+ else
+ Message(FailedFileUploadMsg);
+ until DocumentAttachment.Next() = 0;
+ end;
+ }
+ action(Download)
+ {
+ Caption = 'Download';
+ ToolTip = 'Download the selected file from external storage. If the file is not stored externally, it will be exported from internal storage.';
+ Image = Import;
+
+ trigger OnAction()
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ if Rec."Uploaded Externally" then begin
+ if ExternalStorageImpl.DownloadFromExternalStorage(Rec) then
+ Message(FileDownloadedMsg)
+ else
+ Message(FailedFileDownloadMsg);
+ end else
+ Rec.Export(true);
+ end;
+ }
+ action("Copy from External To Internal")
+ {
+ Enabled = Rec."Deleted Internally" and Rec."Uploaded Externally";
+ Caption = 'Copy from External To Internal';
+ ToolTip = 'Copy the file from external storage to internal storage.';
+ Image = Import;
+
+ trigger OnAction()
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ if ExternalStorageImpl.DownloadFromExternalStorageToInternal(Rec) then
+ Message(FileDownloadedMsg)
+ else
+ Message(FailedFileDownloadMsg);
+ end;
+ }
+ action("Delete from External")
+ {
+ Enabled = not (Rec."Deleted Internally") and Rec."Uploaded Externally";
+ Caption = 'Delete from External';
+ ToolTip = 'Delete the file from external storage.';
+ Image = Delete;
+
+ trigger OnAction()
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ if Confirm(DeleteFileFromExternalStorageQst) then
+ if ExternalStorageImpl.DeleteFromExternalStorage(Rec) then
+ Message(FileDeletedExternalStorageMsg)
+ else
+ Message(FailedFileDeleteExternalStorageMsg);
+ end;
+ }
+ action("Delete from Internal")
+ {
+ Enabled = Rec."Uploaded Externally" and not Rec."Deleted Internally";
+ Caption = 'Delete from Internal';
+ ToolTip = 'Delete the file from Internal storage.';
+ Image = Delete;
+
+ trigger OnAction()
+ var
+ ExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ if Confirm(DeleteFileFromIntStorageQst) then
+ if ExternalStorageImpl.DeleteFromInternalStorage(Rec) then
+ Message(FileDeletedIntStorageMsg)
+ else
+ Message(FailedFileDeleteIntStorageMsg);
+ end;
+ }
+ }
+ area(Navigation)
+ {
+ action("External Storage Setup")
+ {
+ Caption = 'External Storage Setup';
+ ToolTip = 'Configure external storage settings.';
+ Image = Setup;
+ RunObject = page "DA External Storage Setup";
+ }
+ }
+ area(Promoted)
+ {
+ actionref(UploadToExternal_Promoted; "Upload to External") { }
+ actionref(DownloadFromExternal_Promoted; Download) { }
+ actionref(CopyFromExternalToInternal_Promoted; "Copy from External To Internal") { }
+ actionref(DeleteFromExternal_Promoted; "Delete from External") { }
+ actionref(DeleteFromInternal_Promoted; "Delete from Internal") { }
+ }
+ }
+
+ var
+ DeleteFileFromExternalStorageQst: Label 'Are you sure you want to delete this file from external storage?';
+ DeleteFileFromIntStorageQst: Label 'Are you sure you want to delete this file from Internal storage?';
+ FailedFileDeleteExternalStorageMsg: Label 'Failed to delete file from external storage.';
+ FailedFileDeleteIntStorageMsg: Label 'Failed to delete file from Internal storage.';
+ FailedFileDownloadMsg: Label 'Failed to download file.';
+ FailedFileUploadMsg: Label 'Failed to upload file.';
+ FileDeletedExternalStorageMsg: Label 'File deleted successfully from external storage.';
+ FileDeletedIntStorageMsg: Label 'File deleted successfully from Internal storage.';
+ FileDownloadedMsg: Label 'File downloaded successfully.';
+ FileUploadedMsg: Label 'File uploaded successfully.';
+}
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/Permissions/DAExtStorAdmin.PermissionSet.al b/src/Apps/W1/External Storage - Document Attachments/app/src/Permissions/DAExtStorAdmin.PermissionSet.al
new file mode 100644
index 0000000000..1956990135
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/Permissions/DAExtStorAdmin.PermissionSet.al
@@ -0,0 +1,16 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+///
+/// Permission set for External Storage functionality.
+/// Grants necessary permissions to use external storage features.
+///
+permissionset 8751 "DA Ext. Stor. Admin"
+{
+ Assignable = true;
+ Caption = 'DA - External Storage Admin';
+ Permissions = tabledata "DA External Storage Setup" = RIMD;
+}
\ No newline at end of file
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/Setup/DAExternalStorageSetup.Page.al b/src/Apps/W1/External Storage - Document Attachments/app/src/Setup/DAExternalStorageSetup.Page.al
new file mode 100644
index 0000000000..a60dbcd0aa
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/Setup/DAExternalStorageSetup.Page.al
@@ -0,0 +1,144 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using System.Threading;
+using System.Utilities;
+
+///
+/// Setup page for External Storage functionality.
+/// Allows configuration of automatic upload and deletion policies.
+///
+page 8750 "DA External Storage Setup"
+{
+ PageType = Card;
+ SourceTable = "DA External Storage Setup";
+ Caption = 'External Storage Setup';
+ UsageCategory = None;
+ ApplicationArea = Basic, Suite;
+ InsertAllowed = false;
+ DeleteAllowed = false;
+ Extensible = false;
+ Permissions = tabledata "DA External Storage Setup" = rmid,
+ tabledata "Job Queue Entry" = r;
+
+ layout
+ {
+ area(Content)
+ {
+ group(General)
+ {
+ Caption = 'General';
+ field(Enabled; Rec.Enabled)
+ {
+ Importance = Promoted;
+ }
+ field("Root Folder"; Rec."Root Folder")
+ {
+ ShowMandatory = true;
+ Editable = false;
+ trigger OnAssistEdit()
+ begin
+ SelectRootFolder();
+ end;
+ }
+ field(CurrentEnvironmentHash; CurrentEnvironmentHash)
+ {
+ Caption = 'Current Environment Hash';
+ ToolTip = 'Specifies the current environment hash used in the folder structure in external storage.';
+ Editable = false;
+ }
+ }
+ group(UploadAndDeletePolicy)
+ {
+ Caption = 'Upload and Delete Policy';
+ field("Delete from External Storage"; Rec."Delete from External Storage") { }
+ }
+ }
+ }
+ actions
+ {
+ area(Processing)
+ {
+ action(StorageSync)
+ {
+ Caption = 'Storage Sync';
+ Image = Process;
+ ToolTip = 'Run the synchronization job to move document attachments to or from external storage.';
+
+ trigger OnAction()
+ begin
+ Report.Run(Report::"DA External Storage Sync");
+ end;
+ }
+ action(MigrateFiles)
+ {
+ Caption = 'Migrate Files';
+ Image = MoveToNextPeriod;
+ ToolTip = 'Migrate all document attachments from the previous environment/company folder to the current environment/company folder.';
+
+ trigger OnAction()
+ begin
+ Report.Run(Report::"DA External Storage Migration");
+ end;
+ }
+ }
+ area(Navigation)
+ {
+ action(DocumentAttachments)
+ {
+ Caption = 'Document Attachments';
+ Image = Document;
+ ToolTip = 'Open the document attachment list with information about the external storage.';
+ RunObject = page "Document Attachment - External";
+ }
+ }
+ area(Promoted)
+ {
+ actionref(StorageSync_Promoted; StorageSync)
+ {
+ }
+ actionref(MigrateFiles_Promoted; MigrateFiles)
+ {
+ }
+ actionref(DocumentAttachments_Promoted; DocumentAttachments)
+ {
+ }
+ }
+ }
+
+ trigger OnOpenPage()
+ var
+ DAExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ begin
+ if not Rec.Get() then begin
+ Rec.Init();
+ Rec.Insert();
+ end;
+ CurrentEnvironmentHash := DAExternalStorageImpl.GetCurrentEnvironmentHash();
+ end;
+
+ var
+ CurrentEnvironmentHash: Text[16];
+
+ local procedure SelectRootFolder()
+ var
+ DAExternalStorageImpl: Codeunit "DA External Storage Impl.";
+ ConfirmManagement: Codeunit "Confirm Management";
+ NoFolderSelectedLbl: Label 'No folder selected. Do you want to clear the current root folder?';
+ FolderPath: Text;
+ begin
+ FolderPath := DAExternalStorageImpl.SelectRootFolder();
+ if FolderPath <> '' then begin
+ Rec."Root Folder" := CopyStr(FolderPath, 1, MaxStrLen(Rec."Root Folder"));
+ CurrPage.Update();
+ end else begin
+ if ConfirmManagement.GetResponseOrDefault(NoFolderSelectedLbl, false) then
+ Rec."Root Folder" := '';
+ CurrPage.Update();
+ end;
+ end;
+}
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/Setup/DAExternalStorageSetup.Table.al b/src/Apps/W1/External Storage - Document Attachments/app/src/Setup/DAExternalStorageSetup.Table.al
new file mode 100644
index 0000000000..13ee7f91b7
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/Setup/DAExternalStorageSetup.Table.al
@@ -0,0 +1,94 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using Microsoft.Foundation.Attachment;
+using System.Threading;
+
+///
+/// Setup table for External Storage functionality.
+/// Contains configuration settings for automatic upload and deletion policies.
+///
+table 8750 "DA External Storage Setup"
+{
+ Caption = 'External Storage Setup';
+ DataClassification = CustomerContent;
+ Access = Internal;
+ Permissions = tabledata "Job Queue Entry" = rimd;
+
+ fields
+ {
+ field(1; "Primary Key"; Code[10])
+ {
+ Caption = 'Primary Key';
+ DataClassification = SystemMetadata;
+ }
+ field(2; Enabled; Boolean)
+ {
+ Caption = 'Enabled';
+ ToolTip = 'Specifies if the External Storage feature is enabled. Enable this to start using external storage for document attachments.';
+
+ trigger OnValidate()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ DisableSetupErr: Label 'Cannot disable External Storage setup because there are files uploaded using this configuration. Please delete the uploaded files before disabling the setup.';
+ begin
+ if xRec.Enabled and not Rec.Enabled then begin
+ CalcFields("Has Uploaded Files");
+ if "Has Uploaded Files" then
+ Error(DisableSetupErr);
+ end;
+
+ if Enabled then
+ DAFeatureTelemetry.LogFeatureEnabled()
+ else
+ DAFeatureTelemetry.LogFeatureDisabled();
+ end;
+ }
+ field(7; "Delete from External Storage"; Boolean)
+ {
+ Caption = 'Delete External File on Attachment Delete';
+ ToolTip = 'Specifies if files should be deleted from external storage when the attachment is deleted from Business Central.';
+ InitValue = true;
+ }
+ field(10; "Root Folder"; Text[250])
+ {
+ Caption = 'Root Folder';
+ ToolTip = 'Specifies the root folder path where attachments will be stored in external storage.';
+
+ trigger OnValidate()
+ var
+ DAFeatureTelemetry: Codeunit "DA Feature Telemetry";
+ begin
+ if "Root Folder" <> '' then
+ DAFeatureTelemetry.LogRootFolderConfigured();
+ end;
+ }
+ field(12; "Job Queue Entry ID"; Guid)
+ {
+ Caption = 'Job Queue Entry ID';
+ DataClassification = SystemMetadata;
+ Editable = false;
+ ToolTip = 'Specifies the ID of the job queue entry for automatic synchronization.';
+ }
+ field(25; "Has Uploaded Files"; Boolean)
+ {
+ Caption = 'Has Uploaded Files';
+ FieldClass = FlowField;
+ CalcFormula = exist("Document Attachment" where("Uploaded Externally" = const(true)));
+ Editable = false;
+ ToolTip = 'Specifies if files have been uploaded using this configuration.';
+ }
+ }
+
+ keys
+ {
+ key(PK; "Primary Key")
+ {
+ Clustered = true;
+ }
+ }
+}
diff --git a/src/Apps/W1/External Storage - Document Attachments/app/src/Telemetry/DAFeatureTelemetry.Codeunit.al b/src/Apps/W1/External Storage - Document Attachments/app/src/Telemetry/DAFeatureTelemetry.Codeunit.al
new file mode 100644
index 0000000000..a3c6b2c4aa
--- /dev/null
+++ b/src/Apps/W1/External Storage - Document Attachments/app/src/Telemetry/DAFeatureTelemetry.Codeunit.al
@@ -0,0 +1,70 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace Microsoft.ExternalStorage.DocumentAttachments;
+
+using System.Telemetry;
+
+///
+/// Provides telemetry logging for External Storage - Document Attachments feature.
+///
+codeunit 8754 "DA Feature Telemetry"
+{
+ Access = Internal;
+
+ var
+ FeatureTelemetry: Codeunit "Feature Telemetry";
+ ExternalStorageTok: Label 'External Storage - Document Attachments', Locked = true;
+
+ internal procedure LogFeatureEnabled()
+ begin
+ FeatureTelemetry.LogUptake('', ExternalStorageTok, Enum::"Feature Uptake Status"::"Set up");
+ end;
+
+ internal procedure LogFeatureDisabled()
+ begin
+ FeatureTelemetry.LogUptake('', ExternalStorageTok, Enum::"Feature Uptake Status"::"Undiscovered");
+ end;
+
+ internal procedure LogFeatureUsed()
+ begin
+ FeatureTelemetry.LogUptake('', ExternalStorageTok, Enum::"Feature Uptake Status"::Used);
+ end;
+
+ internal procedure LogFileUploaded()
+ begin
+ FeatureTelemetry.LogUsage('', ExternalStorageTok, 'File Uploaded');
+ end;
+
+ internal procedure LogFileDownloaded()
+ begin
+ FeatureTelemetry.LogUsage('', ExternalStorageTok, 'File Downloaded');
+ end;
+
+ internal procedure LogFileDeleted()
+ begin
+ FeatureTelemetry.LogUsage('', ExternalStorageTok, 'File Deleted');
+ end;
+
+ internal procedure LogCompanyMigration()
+ begin
+ FeatureTelemetry.LogUsage('', ExternalStorageTok, 'Company Migration');
+ end;
+
+ internal procedure LogManualSync()
+ begin
+ FeatureTelemetry.LogUsage('', ExternalStorageTok, 'Manual Sync');
+ end;
+
+ internal procedure LogAutoSync()
+ begin
+ FeatureTelemetry.LogUsage('', ExternalStorageTok, 'Auto Sync');
+ end;
+
+ internal procedure LogRootFolderConfigured()
+ begin
+ FeatureTelemetry.LogUsage('', ExternalStorageTok, 'Root Folder Configured');
+ end;
+}
diff --git a/src/System Application/App/External File Storage/src/Scenario/DefaultFileScenarioImpl.Codeunit.al b/src/System Application/App/External File Storage/src/Scenario/DefaultFileScenarioImpl.Codeunit.al
new file mode 100644
index 0000000000..fb6033ae74
--- /dev/null
+++ b/src/System Application/App/External File Storage/src/Scenario/DefaultFileScenarioImpl.Codeunit.al
@@ -0,0 +1,46 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace System.ExternalFileStorage;
+
+codeunit 9459 "Default File Scenario Impl." implements "File Scenario"
+{
+ Access = Internal;
+ InherentPermissions = X;
+ InherentEntitlements = X;
+
+ ///
+ /// Called before adding or modifying a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if the operation is allowed, otherwise false.
+ procedure BeforeAddOrModifyFileScenarioCheck(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SkipInsertOrModify: Boolean
+ begin
+ SkipInsertOrModify := false;
+ end;
+
+ ///
+ /// Called to get additional setup for a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if additional setup is available, otherwise false.
+ procedure GetAdditionalScenarioSetup(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SetupExist: Boolean
+ begin
+ SetupExist := false;
+ end;
+
+ ///
+ /// Called before deleting a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if the delete operation is handled and should not proceed, otherwise false.
+ procedure BeforeDeleteFileScenarioCheck(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SkipDelete: Boolean
+ begin
+ SkipDelete := false;
+ end;
+}
\ No newline at end of file
diff --git a/src/System Application/App/External File Storage/src/Scenario/FileScenario.Codeunit.al b/src/System Application/App/External File Storage/src/Scenario/FileScenario.Codeunit.al
index 82200e7283..b7662d6a8a 100644
--- a/src/System Application/App/External File Storage/src/Scenario/FileScenario.Codeunit.al
+++ b/src/System Application/App/External File Storage/src/Scenario/FileScenario.Codeunit.al
@@ -32,6 +32,17 @@ codeunit 9452 "File Scenario"
exit(FileScenarioImpl.GetFileAccount(Scenario, TempFileAccount));
end;
+ ///
+ /// Gets the file account used by the given file scenario.
+ ///
+ /// The scenario to look for.
+ /// Out parameter holding information about the file account.
+ /// True if an account for the specified scenario was found; otherwise - false.
+ procedure GetSpecificFileAccount(Scenario: Enum "File Scenario"; var TempFileAccount: Record "File Account" temporary): Boolean
+ begin
+ exit(FileScenarioImpl.GetSpecificFileAccount(Scenario, TempFileAccount));
+ end;
+
///
/// Sets a default file account.
///
diff --git a/src/System Application/App/External File Storage/src/Scenario/FileScenario.Enum.al b/src/System Application/App/External File Storage/src/Scenario/FileScenario.Enum.al
index 381e03b4ad..80266851dd 100644
--- a/src/System Application/App/External File Storage/src/Scenario/FileScenario.Enum.al
+++ b/src/System Application/App/External File Storage/src/Scenario/FileScenario.Enum.al
@@ -9,9 +9,10 @@ namespace System.ExternalFileStorage;
/// File scenarios.
/// Used to decouple file accounts from sending files.
///
-enum 9451 "File Scenario"
+enum 9451 "File Scenario" implements "File Scenario"
{
Extensible = true;
+ DefaultImplementation = "File Scenario" = "Default File Scenario Impl.";
///
/// The default file scenario.
@@ -20,5 +21,6 @@ enum 9451 "File Scenario"
value(0; Default)
{
Caption = 'Default';
+ Implementation = "File Scenario" = "Default File Scenario Impl.";
}
}
\ No newline at end of file
diff --git a/src/System Application/App/External File Storage/src/Scenario/FileScenario.Interface.al b/src/System Application/App/External File Storage/src/Scenario/FileScenario.Interface.al
new file mode 100644
index 0000000000..b41b0fa56b
--- /dev/null
+++ b/src/System Application/App/External File Storage/src/Scenario/FileScenario.Interface.al
@@ -0,0 +1,33 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+
+namespace System.ExternalFileStorage;
+
+interface "File Scenario"
+{
+ ///
+ /// Called before adding or modifying a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if the operation is allowed; otherwise false.
+ procedure BeforeAddOrModifyFileScenarioCheck(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SkipInsertOrModify: Boolean;
+
+ ///
+ /// Called to get additional setup for a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if additional setup is available, otherwise false.
+ procedure GetAdditionalScenarioSetup(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SetupExist: Boolean;
+
+ ///
+ /// Called before deleting a file scenario.
+ ///
+ /// The ID of the file scenario.
+ /// The file storage connector.
+ /// True if the delete operation is handled and should not proceed; otherwise false.
+ procedure BeforeDeleteFileScenarioCheck(Scenario: Enum "File Scenario"; Connector: Enum System.ExternalFileStorage."Ext. File Storage Connector") SkipDelete: Boolean;
+}
diff --git a/src/System Application/App/External File Storage/src/Scenario/FileScenarioImpl.Codeunit.al b/src/System Application/App/External File Storage/src/Scenario/FileScenarioImpl.Codeunit.al
index a4903091fb..f236a1dd9b 100644
--- a/src/System Application/App/External File Storage/src/Scenario/FileScenarioImpl.Codeunit.al
+++ b/src/System Application/App/External File Storage/src/Scenario/FileScenarioImpl.Codeunit.al
@@ -20,17 +20,29 @@ codeunit 9453 "File Scenario Impl."
FileScenario: Record "File Scenario";
FileAccounts: Codeunit "File Account";
begin
- FileAccounts.GetAllAccounts(TempAllFileAccounts);
-
// Find the account for the provided scenario
- if FileScenario.Get(Scenario) then
+ if GetSpecificFileAccount(Scenario, TempFileAccount) then
+ exit(true);
+
+ // Fallback to the default account if the scenario isn't mapped or the mapped account doesn't exist
+ FileAccounts.GetAllAccounts(TempAllFileAccounts);
+ if FileScenario.Get(Enum::"File Scenario"::Default) then
if TempAllFileAccounts.Get(FileScenario."Account Id", FileScenario.Connector) then begin
TempFileAccount := TempAllFileAccounts;
exit(true);
end;
+ end;
- // Fallback to the default account if the scenario isn't mapped or the mapped account doesn't exist
- if FileScenario.Get(Enum::"File Scenario"::Default) then
+ procedure GetSpecificFileAccount(Scenario: Enum "File Scenario"; var TempFileAccount: Record "File Account" temporary): Boolean
+ var
+ TempAllFileAccounts: Record "File Account" temporary;
+ FileScenario: Record "File Scenario";
+ FileAccounts: Codeunit "File Account";
+ begin
+ FileAccounts.GetAllAccounts(TempAllFileAccounts);
+
+ // Find the account for the provided scenario
+ if FileScenario.Get(Scenario) then
if TempAllFileAccounts.Get(FileScenario."Account Id", FileScenario.Connector) then begin
TempFileAccount := TempAllFileAccounts;
exit(true);
@@ -174,6 +186,8 @@ codeunit 9453 "File Scenario Impl."
TempSelectedFileAccScenarios: Record "File Account Scenario" temporary;
FileScenario: Record "File Scenario";
FileScenariosForAccount: Page "File Scenarios for Account";
+ FileScenarioInterface: Interface "File Scenario";
+ FileScenarioEnum: Enum "File Scenario";
begin
FileAccountImpl.CheckPermissions();
@@ -193,6 +207,11 @@ codeunit 9453 "File Scenario Impl."
exit;
repeat
+ FileScenarioEnum := Enum::"File Scenario".FromInteger(TempSelectedFileAccScenarios.Scenario);
+ FileScenarioInterface := FileScenarioEnum;
+ if FileScenarioInterface.BeforeAddOrModifyFileScenarioCheck(FileScenarioEnum, TempSelectedFileAccScenarios.Connector) then
+ exit;
+
if not FileScenario.Get(TempSelectedFileAccScenarios.Scenario) then begin
FileScenario."Account Id" := TempFileAccountScenario."Account Id";
FileScenario.Connector := TempFileAccountScenario.Connector;
@@ -289,6 +308,8 @@ codeunit 9453 "File Scenario Impl."
procedure DeleteScenario(var TempFileAccountScenario: Record "File Account Scenario" temporary): Boolean
var
FileScenario: Record "File Scenario";
+ FileScenarioInterface: Interface "File Scenario";
+ FileScenarioEnum: Enum "File Scenario";
begin
FileAccountImpl.CheckPermissions();
@@ -301,7 +322,10 @@ codeunit 9453 "File Scenario Impl."
FileScenario.SetRange("Account Id", TempFileAccountScenario."Account Id");
FileScenario.SetRange(Connector, TempFileAccountScenario.Connector);
- FileScenario.DeleteAll();
+ FileScenarioEnum := Enum::"File Scenario".FromInteger(TempFileAccountScenario.Scenario);
+ FileScenarioInterface := FileScenarioEnum;
+ if not FileScenarioInterface.BeforeDeleteFileScenarioCheck(FileScenarioEnum, TempFileAccountScenario.Connector) then
+ FileScenario.DeleteAll();
end;
until TempFileAccountScenario.Next() = 0;
diff --git a/src/System Application/App/External File Storage/src/Scenario/FileScenarioSetup.Page.al b/src/System Application/App/External File Storage/src/Scenario/FileScenarioSetup.Page.al
index 6470b1f0d0..8a3fc7b11c 100644
--- a/src/System Application/App/External File Storage/src/Scenario/FileScenarioSetup.Page.al
+++ b/src/System Application/App/External File Storage/src/Scenario/FileScenarioSetup.Page.al
@@ -80,6 +80,25 @@ page 9452 "File Scenario Setup"
group(Scenario)
{
+ action(ScenarioSetup)
+ {
+ Visible = (TypeOfEntry = TypeOfEntry::Scenario) and CanUserManageFileSetup;
+ Caption = 'Additional Scenario Setup';
+ ToolTip = 'Additional scenario setup for the selected scenario.';
+ Image = Setup;
+ Scope = Repeater;
+ trigger OnAction()
+ var
+ FileScenarioInterface: Interface "File Scenario";
+ FileScenarioEnum: Enum "File Scenario";
+ NoSetupAvailableMsg: Label 'No additional setup is available for this scenario.';
+ begin
+ FileScenarioEnum := Enum::"File Scenario".FromInteger(Rec.Scenario);
+ FileScenarioInterface := FileScenarioEnum;
+ if not FileScenarioInterface.GetAdditionalScenarioSetup(FileScenarioEnum, Rec.Connector) then
+ Message(NoSetupAvailableMsg);
+ end;
+ }
action(ChangeAccount)
{
Visible = (TypeOfEntry = TypeOfEntry::Scenario) and CanUserManageFileSetup;
@@ -123,6 +142,7 @@ page 9452 "File Scenario Setup"
group(Category_Process)
{
actionref(AddScenario_Promoted; AddScenario) { }
+ actionref(ScenarioSetup_Promoted; ScenarioSetup) { }
actionref(ChangeAccount_Promoted; ChangeAccount) { }
actionref(Unassign_Promoted; Unassign) { }
}
diff --git a/src/System Application/App/External File Storage/src/Scenario/FileScenariosFactBox.Page.al b/src/System Application/App/External File Storage/src/Scenario/FileScenariosFactBox.Page.al
index 533492a3a7..c73e46617b 100644
--- a/src/System Application/App/External File Storage/src/Scenario/FileScenariosFactBox.Page.al
+++ b/src/System Application/App/External File Storage/src/Scenario/FileScenariosFactBox.Page.al
@@ -39,4 +39,28 @@ page 9453 "File Scenarios FactBox"
}
}
}
+ actions
+ {
+ area(Processing)
+ {
+ action(ScenarioSetup)
+ {
+ Caption = 'Additional Scenario Setup';
+ ToolTip = 'Additional scenario setup for the selected scenario.';
+ Image = Setup;
+ Scope = Repeater;
+ trigger OnAction()
+ var
+ FileScenarioInterface: Interface "File Scenario";
+ FileScenarioEnum: Enum "File Scenario";
+ NoSetupAvailableMsg: Label 'No additional setup is available for this scenario.';
+ begin
+ FileScenarioEnum := Rec.Scenario;
+ FileScenarioInterface := FileScenarioEnum;
+ if not FileScenarioInterface.GetAdditionalScenarioSetup(Rec.Scenario, Rec.Connector) then
+ Message(NoSetupAvailableMsg);
+ end;
+ }
+ }
+ }
}
\ No newline at end of file