├── .gitignore ├── images ├── conversion-logs.png ├── pages-main-menu.png ├── setup-enable-notes.png ├── pages-conversion-settings.png ├── setup-enable-create-audit-fields1.png └── setup-enable-create-audit-fields2.png ├── src ├── classes │ ├── ConvertNotesInstallHandler.cls-meta.xml │ ├── ConvertNotesRunOnceController.cls-meta.xml │ ├── ConvertNotesInstallHandlerTest.cls-meta.xml │ ├── ConvertNotesRunOnceControllerTest.cls-meta.xml │ ├── ConvertNotesScheduleController.cls-meta.xml │ ├── ConvertNotesScheduleControllerTest.cls-meta.xml │ ├── ConvertNotesSettingsController.cls-meta.xml │ ├── ConvertNotesSettingsControllerTest.cls-meta.xml │ ├── ConvertNotesToContentNotesLogger.cls-meta.xml │ ├── ConvertNotesToContentNotesOptions.cls-meta.xml │ ├── ConvertNotesToContentNotesService.cls-meta.xml │ ├── ConvertNotesToContentNotesBatchable.cls-meta.xml │ ├── ConvertNotesToContentNotesBatchableTest.cls-meta.xml │ ├── ConvertNotesToContentNotesQueueable.cls-meta.xml │ ├── ConvertNotesToContentNotesQueueableTest.cls-meta.xml │ ├── ConvertNotesToContentNotesSchedulable.cls-meta.xml │ ├── ConvertNotesToContentNotesScheduleTest.cls-meta.xml │ ├── ConvertNotesToContentNotesServiceTest.cls-meta.xml │ ├── ConvertNotesToContentNotesTestFactory.cls-meta.xml │ ├── ConvertNotesInstallHandler.cls │ ├── ConvertNotesScheduleController.cls │ ├── ConvertNotesToContentNotesScheduleTest.cls │ ├── ConvertNotesToContentNotesTestFactory.cls │ ├── ConvertNotesToContentNotesOptions.cls │ ├── ConvertNotesInstallHandlerTest.cls │ ├── ConvertNotesToContentNotesSchedulable.cls │ ├── ConvertNotesRunOnceController.cls │ ├── ConvertNotesSettingsController.cls │ ├── ConvertNotesScheduleControllerTest.cls │ ├── ConvertNotesSettingsControllerTest.cls │ ├── ConvertNotesToContentNotesQueueable.cls │ ├── ConvertNotesToContentNotesBatchable.cls │ ├── ConvertNotesToContentNotesLogger.cls │ ├── ConvertNotesRunOnceControllerTest.cls │ ├── ConvertNotesToContentNotesQueueableTest.cls │ ├── ConvertNotesToContentNotesService.cls │ ├── ConvertNotesToContentNotesBatchableTest.cls │ └── ConvertNotesToContentNotesServiceTest.cls ├── triggers │ ├── ConvertNotesToContentNotesTrigger.trigger-meta.xml │ └── ConvertNotesToContentNotesTrigger.trigger ├── tabs │ ├── Convert_Notes_to_ContentNotes_Log__c.tab │ └── Convert_Notes_to_ContentNotes.tab ├── pages │ ├── ConvertNotesFAQPage.page-meta.xml │ ├── ConvertNotesMenuPage.page-meta.xml │ ├── ConvertNotesRunOncePage.page-meta.xml │ ├── ConvertNotesSchedulePage.page-meta.xml │ ├── ConvertNotesSettingsPage.page-meta.xml │ ├── ConvertNotesFAQPage.page │ ├── ConvertNotesMenuPage.page │ ├── ConvertNotesSettingsPage.page │ ├── ConvertNotesSchedulePage.page │ └── ConvertNotesRunOncePage.page ├── applications │ ├── Convert_Notes_to_ContentNotes.app │ └── Convert_Notes_to_ContentNotes_Lightning.app ├── objects │ ├── ContentVersion.object │ ├── Convert_Notes_to_ContentNotes_Settings__c.object │ └── Convert_Notes_to_ContentNotes_Log__c.object ├── flexipages │ └── Convert_Notes_to_Enhanced_Notes_Log_Record_Page.flexipage ├── permissionsets │ └── Convert_Notes_to_ContentNotes.permissionset ├── package.xml └── layouts │ └── Convert_Notes_to_ContentNotes_Log__c-Convert Notes to Enhanced Notes Log Layout.layout ├── .github └── FUNDING.yml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | config/ 2 | *.sublime-project 3 | *.sublime-workspace 4 | *.iml 5 | -------------------------------------------------------------------------------- /images/conversion-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douglascayers/sfdc-convert-notes-to-chatter-notes/HEAD/images/conversion-logs.png -------------------------------------------------------------------------------- /images/pages-main-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douglascayers/sfdc-convert-notes-to-chatter-notes/HEAD/images/pages-main-menu.png -------------------------------------------------------------------------------- /images/setup-enable-notes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douglascayers/sfdc-convert-notes-to-chatter-notes/HEAD/images/setup-enable-notes.png -------------------------------------------------------------------------------- /images/pages-conversion-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douglascayers/sfdc-convert-notes-to-chatter-notes/HEAD/images/pages-conversion-settings.png -------------------------------------------------------------------------------- /images/setup-enable-create-audit-fields1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douglascayers/sfdc-convert-notes-to-chatter-notes/HEAD/images/setup-enable-create-audit-fields1.png -------------------------------------------------------------------------------- /images/setup-enable-create-audit-fields2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douglascayers/sfdc-convert-notes-to-chatter-notes/HEAD/images/setup-enable-create-audit-fields2.png -------------------------------------------------------------------------------- /src/classes/ConvertNotesInstallHandler.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesRunOnceController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesInstallHandlerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesRunOnceControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesScheduleController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesScheduleControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesSettingsController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesSettingsControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesLogger.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesOptions.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesService.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesBatchable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesBatchableTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesQueueable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesQueueableTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesSchedulable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesScheduleTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesServiceTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesTestFactory.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/triggers/ConvertNotesToContentNotesTrigger.trigger-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/tabs/Convert_Notes_to_ContentNotes_Log__c.tab: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | false 5 | Custom26: Flag 6 | 7 | -------------------------------------------------------------------------------- /src/tabs/Convert_Notes_to_ContentNotes.tab: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | Custom67: Gears 6 | ConvertNotesMenuPage 7 | 8 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesFAQPage.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | false 5 | false 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesMenuPage.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | false 5 | false 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesRunOncePage.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | false 5 | false 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesSchedulePage.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | false 5 | false 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesSettingsPage.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | false 5 | false 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/applications/Convert_Notes_to_ContentNotes.app: -------------------------------------------------------------------------------- 1 | 2 | 3 | Convert_Notes_to_ContentNotes 4 | 5 | standard-File 6 | Convert_Notes_to_ContentNotes 7 | Convert_Notes_to_ContentNotes_Log__c 8 | 9 | -------------------------------------------------------------------------------- /src/applications/Convert_Notes_to_ContentNotes_Lightning.app: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #0070D2 5 | 6 | Large 7 | 8 | Standard 9 | standard-home 10 | standard-ContentNote 11 | Convert_Notes_to_ContentNotes 12 | Convert_Notes_to_ContentNotes_Log__c 13 | Lightning 14 | 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: https://www.paypal.me/douglascayers/ # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /src/triggers/ConvertNotesToContentNotesTrigger.trigger: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | * 4 | * Enqueues a job to convert the notes into enhanced notes. 5 | * Note, some triggers aren't fired for actions performed in Case Feed: 6 | * https://success.salesforce.com/issues_view?id=a1p300000008YTEAA2 7 | */ 8 | trigger ConvertNotesToContentNotesTrigger on Note ( after insert ) { 9 | 10 | // we use the instance rather than org defaults here to support 11 | // overrides on a user or profile level 12 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getInstance(); 13 | 14 | if ( settings.convert_in_near_real_time__c ) { 15 | 16 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 17 | 18 | ConvertNotesToContentNotesQueueable queueable = new ConvertNotesToContentNotesQueueable( Trigger.newMap.keySet(), options, Network.getNetworkId() ); 19 | 20 | System.enqueueJob( queueable ); 21 | 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesInstallHandler.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | public with sharing class ConvertNotesInstallHandler implements System.InstallHandler { 5 | 6 | public void onInstall( InstallContext context ) { 7 | 8 | try { 9 | 10 | Boolean isNew = ( context.previousVersion() == null ); 11 | 12 | // for new installs then populate the custom setting 13 | if ( isNew ) { 14 | 15 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 16 | 17 | if ( String.isBlank( settings.id ) ) { 18 | 19 | settings.Convert_in_Near_Real_Time__c = false; 20 | settings.Delete_Note_Once_Converted__c = false; 21 | 22 | insert settings; 23 | 24 | } 25 | 26 | } 27 | 28 | } catch ( Exception e ) { 29 | 30 | // not really interested in the error 31 | // this is just a convenience to pre-populate custom setting 32 | 33 | } 34 | 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesScheduleController.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | public with sharing class ConvertNotesScheduleController { 5 | 6 | public Convert_Notes_to_ContentNotes_Settings__c settings { get; set; } 7 | 8 | public String message { get; set; } 9 | 10 | public Boolean success { get; set; } 11 | 12 | public ConvertNotesScheduleController() { 13 | 14 | this.settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 15 | 16 | } 17 | 18 | public void save() { 19 | 20 | SavePoint sp = Database.setSavePoint(); 21 | 22 | ID originalId = this.settings.id; 23 | 24 | try { 25 | 26 | upsert this.settings; 27 | 28 | this.message = 'Settings saved successfully!'; 29 | this.success = true; 30 | 31 | } catch ( Exception e ) { 32 | 33 | System.debug( LoggingLevel.ERROR, e.getMessage() + ' : ' + e.getStackTraceString() ); 34 | 35 | Database.rollback( sp ); 36 | 37 | this.settings.id = originalId; 38 | 39 | this.message = e.getMessage(); 40 | this.success = false; 41 | 42 | } 43 | 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesScheduleTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesToContentNotesScheduleTest { 6 | 7 | @isTest 8 | static void test_schedulable() { 9 | 10 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 11 | settings.Convert_in_Near_Real_Time__c = false; 12 | settings.Delete_Note_Once_Converted__c = true; 13 | 14 | upsert settings; 15 | 16 | Test.startTest(); 17 | 18 | System.schedule( 'Conversion Job', '0 0 13 * * ?', new ConvertNotesToContentNotesSchedulable() ); 19 | 20 | Test.stopTest(); 21 | 22 | System.assertEquals( 0, [ SELECT count() FROM Convert_Notes_To_ContentNotes_Log__c ] ); 23 | 24 | } 25 | 26 | @isTest 27 | static void test_errors() { 28 | 29 | ConvertNotesToContentNotesSchedulable job = new ConvertNotesToContentNotesSchedulable(); 30 | 31 | job.batchSize = null; // will cause error 32 | 33 | Test.startTest(); 34 | 35 | System.schedule( 'Conversion Job', '0 0 13 * * ?', job ); 36 | 37 | Test.stopTest(); 38 | 39 | System.assertEquals( 1, [ SELECT count() FROM Convert_Notes_To_ContentNotes_Log__c ] ); 40 | 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesTestFactory.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | * 4 | * Utility for creating test data. 5 | * Note, non @isTest methods in a test class will yield 6 | * inaccurate code coverage metrics, that is why this class exists. 7 | * This class gets code coverage by being leveraged directly in test classes. 8 | * 9 | * https://help.salesforce.com/articleView?id=Why-is-a-Test-class-evaluated-as-part-of-the-Organization-s-Code-Coverage&language=en_US&type=1 10 | */ 11 | public with sharing class ConvertNotesToContentNotesTestFactory { 12 | 13 | public static User newUser( ID profileId, String firstName, String lastName, String email ) { 14 | return newUser( profileId, null, firstName, lastName, email ); 15 | } 16 | 17 | public static User newUser( ID profileId, ID roleId, String firstName, String lastName, String email ) { 18 | Integer rand = Math.round( Math.random() * 1000 ); 19 | return new User( 20 | isActive = true, 21 | profileId = profileId, 22 | userRoleId = roleId, 23 | alias = firstName.substring(0,1) + lastName.substring(1,5), 24 | firstName = firstName, 25 | lastName = lastName, 26 | email = email, 27 | username = rand + email, 28 | emailEncodingKey = 'UTF-8', 29 | languageLocaleKey = 'en_US', 30 | localeSidKey = 'en_US', 31 | timeZoneSidKey = 'America/Chicago' 32 | ); 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Doug Ayers 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesOptions.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | * 4 | * Represents configuration options to pass to conversion service 5 | * to influence how the conversion will be handled with the notes. 6 | */ 7 | public with sharing class ConvertNotesToContentNotesOptions { 8 | 9 | // Should the Notes be deleted upon conversion to ContentNote? 10 | // Deleting them reduces redundant data storage. 11 | // Make sure you've backed up your data before enabling this. 12 | public Boolean deleteNotesUponConversion { get; set; } 13 | 14 | // To help mitigate hitting the max content publication limit then 15 | // admins can specify their own soft limit to, hopefully, predictably 16 | // stop the conversion job prior to hitting the governor limit 17 | public Integer maxRecordsToConvert { get; set; } 18 | 19 | // Scope the conversion to just notes related to specific records 20 | // if null then ALL notes in the system will be converted 21 | // if empty then NO notes will be converted 22 | // if non-empty then only notes related to those records will be converted 23 | public Set parentIds { get; set; } 24 | 25 | public ConvertNotesToContentNotesOptions() { 26 | this( Convert_Notes_to_ContentNotes_Settings__c.getInstance() ); 27 | } 28 | 29 | public ConvertNotesToContentNotesOptions( Convert_Notes_to_ContentNotes_Settings__c settings ) { 30 | this.deleteNotesUponConversion = settings.delete_note_once_converted__c; 31 | this.maxRecordsToConvert = 150000; 32 | this.parentIds = null; 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesInstallHandlerTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesInstallHandlerTest { 6 | 7 | @isTest 8 | static void test_new_install() { 9 | 10 | Test.startTest(); 11 | 12 | Test.testInstall( new ConvertNotesInstallHandler(), null ); 13 | 14 | Test.stopTest(); 15 | 16 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 17 | 18 | System.assert( String.isNotBlank( settings.id ), 'id should not be blank' ); 19 | System.assertEquals( false, settings.Convert_in_Near_Real_Time__c ); 20 | System.assertEquals( false, settings.Delete_Note_Once_Converted__c ); 21 | 22 | } 23 | 24 | @isTest 25 | static void test_upgrade() { 26 | 27 | Convert_Notes_to_ContentNotes_Settings__c preSettings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 28 | preSettings.Convert_in_Near_Real_Time__c = true; 29 | preSettings.Delete_Note_Once_Converted__c = true; 30 | 31 | upsert preSettings; 32 | 33 | Test.startTest(); 34 | 35 | Test.testInstall( new ConvertNotesInstallHandler(), new Version( 1, 0 ) ); 36 | 37 | Test.stopTest(); 38 | 39 | Convert_Notes_to_ContentNotes_Settings__c postSettings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 40 | 41 | System.assertEquals( preSettings.id, postSettings.id ); 42 | System.assertEquals( preSettings.Convert_in_Near_Real_Time__c, postSettings.Convert_in_Near_Real_Time__c ); 43 | System.assertEquals( preSettings.Delete_Note_Once_Converted__c, postSettings.Delete_Note_Once_Converted__c ); 44 | 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesSchedulable.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | * 4 | * Designed for scheduling periodic job to perform batch conversion of notes. 5 | */ 6 | global without sharing class ConvertNotesToContentNotesSchedulable implements System.Schedulable { 7 | 8 | public Integer batchSize { get; set; } 9 | 10 | global ConvertNotesToContentNotesSchedulable() { 11 | this( 200 ); 12 | } 13 | 14 | global ConvertNotesToContentNotesSchedulable( Integer batchSize ) { 15 | this.batchSize = batchSize; 16 | } 17 | 18 | public void execute( SchedulableContext context ) { 19 | 20 | SavePoint sp = Database.setSavePoint(); 21 | 22 | DateTime startTime = DateTime.now(); 23 | 24 | try { 25 | 26 | // we use the instance rather than org defaults here to support 27 | // overrides on a user or profile level 28 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getInstance(); 29 | 30 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 31 | 32 | System.debug( '[ConvertNotesToContentNotesSchedulable.execute] Executing: ' + context ); 33 | System.debug( '[ConvertNotesToContentNotesSchedulable.execute] Options: ' + options ); 34 | System.debug( '[ConvertNotesToContentNotesSchedulable.execute] Batch Size: ' + this.batchSize ); 35 | 36 | ConvertNotesToContentNotesBatchable batchable = new ConvertNotesToContentNotesBatchable( options ); 37 | 38 | Database.executeBatch( batchable, this.batchSize ); 39 | 40 | } catch ( Exception e ) { 41 | 42 | Database.rollback( sp ); 43 | 44 | ConvertNotesToContentNotesLogger.log( context.getTriggerId(), e ); 45 | 46 | } finally { 47 | 48 | ConvertNotesToContentNotesLogger.sendApexExceptionEmailIfAnyErrorsSince( startTime ); 49 | 50 | } 51 | 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesRunOnceController.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | public with sharing class ConvertNotesRunOnceController { 5 | 6 | public ConvertNotesToContentNotesOptions options { get; set; } 7 | 8 | public String parentIdsCsv { get; set; } 9 | 10 | public Integer batchSize { get; set; } 11 | 12 | public String message { get; set; } 13 | 14 | public Boolean success { get; set; } 15 | 16 | public ConvertNotesRunOnceController() { 17 | this.options = new ConvertNotesToContentNotesOptions( Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults() ); 18 | this.batchSize = 100; 19 | this.parentIdsCsv = ''; 20 | } 21 | 22 | public void submitJob() { 23 | 24 | SavePoint sp = Database.setSavePoint(); 25 | 26 | try { 27 | 28 | Set parentIds = new Set(); 29 | 30 | for ( String parentId : this.parentIdsCsv.split( ',' ) ) { 31 | if ( String.isNotBlank( parentId ) ) { 32 | parentIds.add( parentId.trim() ); 33 | } 34 | } 35 | 36 | if ( !parentIds.isEmpty() ) { 37 | this.options.parentIds = parentIds; 38 | } 39 | 40 | System.debug( 'submitting one-time conversion job' ); 41 | System.debug( 'options: ' + this.options ); 42 | System.debug( 'batchSize: ' + this.batchSize ); 43 | 44 | ConvertNotesToContentNotesBatchable job = new ConvertNotesToContentNotesBatchable( this.options ); 45 | 46 | ID jobId = Database.executeBatch( job, this.batchSize ); 47 | 48 | this.message = 'Conversion batch job submitted: ' + jobId; 49 | this.success = true; 50 | 51 | } catch ( Exception e ) { 52 | 53 | System.debug( LoggingLevel.ERROR, e.getMessage() + ' : ' + e.getStackTraceString() ); 54 | 55 | Database.rollback( sp ); 56 | 57 | this.message = e.getMessage(); 58 | this.success = false; 59 | 60 | } 61 | 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesSettingsController.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | public with sharing class ConvertNotesSettingsController { 5 | 6 | @TestVisible 7 | private Boolean mockIsFormValid { get; set; } 8 | 9 | @TestVisible 10 | private Exception mockException { get; set; } 11 | 12 | // ---------------------------------------------------------------------- 13 | 14 | public Convert_Notes_to_ContentNotes_Settings__c settings { get; set; } 15 | 16 | public String message { get; set; } 17 | 18 | public Boolean success { get; set; } 19 | 20 | public ConvertNotesSettingsController() { 21 | this.settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 22 | } 23 | 24 | public void save() { 25 | 26 | SavePoint sp = Database.setSavePoint(); 27 | 28 | ID originalId = this.settings.id; 29 | 30 | try { 31 | 32 | Boolean isFormValid = ( this.settings.Delete_Note_Once_Converted__c != null ); 33 | 34 | if ( Test.isRunningTest() ) { 35 | 36 | if ( this.mockException != null ) { 37 | throw this.mockException; 38 | } 39 | 40 | if ( this.mockIsFormValid != null ) { 41 | isFormValid = this.mockIsFormValid; 42 | } 43 | 44 | } 45 | 46 | if ( isFormValid ) { 47 | 48 | upsert this.settings; 49 | 50 | // refresh any values set on insert 51 | this.settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 52 | 53 | this.message = 'Settings saved successfully!'; 54 | this.success = true; 55 | 56 | } else { 57 | 58 | this.success = false; 59 | this.message = 'Please answer all questions to configure conversion options.'; 60 | 61 | } 62 | 63 | } catch ( Exception e ) { 64 | 65 | System.debug( LoggingLevel.ERROR, e.getMessage() + ' : ' + e.getStackTraceString() ); 66 | 67 | Database.rollback( sp ); 68 | 69 | this.settings.id = originalId; 70 | 71 | this.message = e.getMessage(); 72 | this.success = false; 73 | 74 | } 75 | 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesScheduleControllerTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesScheduleControllerTest { 6 | 7 | @isTest 8 | static void test_save_new_settings() { 9 | 10 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 11 | settings.Convert_in_Near_Real_Time__c = false; 12 | settings.Delete_Note_Once_Converted__c = true; 13 | 14 | Test.startTest(); 15 | 16 | ConvertNotesScheduleController controller = new ConvertNotesScheduleController(); 17 | 18 | controller.settings = settings; 19 | 20 | controller.save(); 21 | 22 | Test.stopTest(); 23 | 24 | System.assertEquals( true, controller.success ); 25 | System.assert( controller.message.containsIgnoreCase( 'Settings saved successfully' ) ); 26 | 27 | } 28 | 29 | @isTest 30 | static void test_update_existing_settings() { 31 | 32 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 33 | settings.Convert_in_Near_Real_Time__c = false; 34 | settings.Delete_Note_Once_Converted__c = true; 35 | 36 | upsert settings; 37 | 38 | Test.startTest(); 39 | 40 | ConvertNotesScheduleController controller = new ConvertNotesScheduleController(); 41 | 42 | controller.settings = settings; 43 | 44 | controller.save(); 45 | 46 | Test.stopTest(); 47 | 48 | System.assertEquals( true, controller.success ); 49 | System.assert( controller.message.containsIgnoreCase( 'Settings saved successfully' ) ); 50 | 51 | } 52 | 53 | @isTest 54 | static void test_save_error() { 55 | 56 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 57 | settings.Convert_in_Near_Real_Time__c = false; 58 | settings.Delete_Note_Once_Converted__c = true; 59 | 60 | upsert settings; 61 | 62 | Test.startTest(); 63 | 64 | ConvertNotesScheduleController controller = new ConvertNotesScheduleController(); 65 | 66 | controller.settings = settings; 67 | 68 | delete settings; // will cause error on save because ID field is still populated on controller's reference 69 | 70 | controller.save(); 71 | 72 | Test.stopTest(); 73 | 74 | System.assertEquals( false, controller.success ); 75 | 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/objects/ContentVersion.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Original_Record_ID__c 5 | false 6 | The original Note or Attachment ID this ContentVersion was converted from. For history tracking purpose and used in conversion process to know the parent record to link the new content version to. 7 | true 8 | The original Note or Attachment ID this ContentVersion was converted from. For history tracking purpose and used in conversion process to know the parent record to link the new content version to. 9 | 10 | 255 11 | false 12 | false 13 | Text 14 | false 15 | 16 | 17 | Original_Record_Owner_ID__c 18 | false 19 | The owner ID of the original Note or Attachment that was converted. 20 | true 21 | The owner ID of the original Note or Attachment that was converted. 22 | 23 | 255 24 | false 25 | false 26 | Text 27 | false 28 | 29 | 30 | Original_Record_Parent_ID__c 31 | false 32 | The original Note or Attachment parent ID this ContentVersion was converted from. For history tracking purpose and used in conversion process to know the parent record to link the new content version to. 33 | true 34 | The original Note or Attachment parent ID this ContentVersion was converted from. For history tracking purpose and used in conversion process to know the parent record to link the new content version to. 35 | 36 | 255 37 | false 38 | false 39 | Text 40 | false 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/flexipages/Convert_Notes_to_Enhanced_Notes_Log_Record_Page.flexipage: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | numVisibleActions 7 | 3 8 | 9 | force:highlightsPanel 10 | 11 | Replace 12 | header 13 | Region 14 | 15 | 16 | 17 | force:detailPanel 18 | 19 | Replace 20 | detailTabContent 21 | Facet 22 | 23 | 24 | 25 | 26 | active 27 | true 28 | 29 | 30 | body 31 | detailTabContent 32 | 33 | 34 | title 35 | Standard.Tab.detail 36 | 37 | flexipage:tab 38 | 39 | Replace 40 | maintabs 41 | Facet 42 | 43 | 44 | 45 | 46 | tabs 47 | maintabs 48 | 49 | flexipage:tabset 50 | 51 | Replace 52 | main 53 | Region 54 | 55 | 56 | Replace 57 | sidebar 58 | Region 59 | 60 | Convert Notes to Enhanced Notes Log Record Page 61 | flexipage__default_rec_L 62 | Convert_Notes_to_ContentNotes_Log__c 63 | 66 | RecordPage 67 | 68 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesFAQPage.page: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 |
22 | 23 |
24 | 25 |
26 | 32 |

33 | Convert Notes to Enhanced Notes 34 |

35 |
36 | 37 |
38 | 39 |
40 |
41 |

Frequently Asked Questions

42 |

43 | Please consult the FAQ on the project home page. 44 |

45 |
46 |
47 | 48 |
49 | 50 |
51 |
52 |

Bug or Enhancement Request

53 |

54 | If you encounter an issue not mentioned there or have an idea for a new feature or improvement, 55 | you can submit an issue on the project's issue tracker. 56 |

57 |
58 |
59 | 60 |
61 | 62 |
63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesSettingsControllerTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesSettingsControllerTest { 6 | 7 | @isTest 8 | static void test_save_new_settings() { 9 | 10 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 11 | settings.Convert_in_Near_Real_Time__c = false; 12 | settings.Delete_Note_Once_Converted__c = true; 13 | 14 | Test.startTest(); 15 | 16 | ConvertNotesSettingsController controller = new ConvertNotesSettingsController(); 17 | 18 | controller.settings = settings; 19 | 20 | controller.save(); 21 | 22 | Test.stopTest(); 23 | 24 | System.assertEquals( true, controller.success ); 25 | System.assert( controller.message.containsIgnoreCase( 'Settings saved successfully' ) ); 26 | 27 | } 28 | 29 | @isTest 30 | static void test_update_existing_settings() { 31 | 32 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 33 | settings.Convert_in_Near_Real_Time__c = false; 34 | settings.Delete_Note_Once_Converted__c = true; 35 | 36 | upsert settings; 37 | 38 | Test.startTest(); 39 | 40 | ConvertNotesSettingsController controller = new ConvertNotesSettingsController(); 41 | 42 | controller.settings = settings; 43 | 44 | controller.save(); 45 | 46 | Test.stopTest(); 47 | 48 | System.assertEquals( true, controller.success ); 49 | System.assert( controller.message.containsIgnoreCase( 'Settings saved successfully' ) ); 50 | 51 | } 52 | 53 | @isTest 54 | static void test_save_error() { 55 | 56 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 57 | settings.Convert_in_Near_Real_Time__c = false; 58 | settings.Delete_Note_Once_Converted__c = true; 59 | 60 | upsert settings; 61 | 62 | Test.startTest(); 63 | 64 | ConvertNotesSettingsController controller = new ConvertNotesSettingsController(); 65 | 66 | controller.settings = settings; 67 | 68 | controller.mockIsFormValid = false; 69 | 70 | controller.save(); 71 | 72 | System.assertEquals( false, controller.success ); 73 | System.assert( controller.message.containsIgnoreCase( 'Please answer all questions' ) ); 74 | 75 | controller.mockIsFormValid = true; 76 | controller.mockException = new System.NullPointerException(); 77 | 78 | controller.save(); 79 | 80 | Test.stopTest(); 81 | 82 | System.assertEquals( false, controller.success ); 83 | 84 | } 85 | 86 | } -------------------------------------------------------------------------------- /src/permissionsets/Convert_Notes_to_ContentNotes.permissionset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Convert_Notes_to_ContentNotes 5 | true 6 | 7 | 8 | Convert_Notes_to_ContentNotes_Lightning 9 | true 10 | 11 | Grants access to the Conversion Settings tab and visualforce pages. This should only be granted to System Administrators. 12 | 13 | true 14 | Convert_Notes_to_ContentNotes_Log__c.Detail__c 15 | true 16 | 17 | 18 | true 19 | Convert_Notes_to_ContentNotes_Log__c.Job_ID__c 20 | true 21 | 22 | 23 | false 24 | Convert_Notes_to_ContentNotes_Log__c.Log_Time__c 25 | true 26 | 27 | 28 | true 29 | Convert_Notes_to_ContentNotes_Log__c.New_Note_ID__c 30 | true 31 | 32 | 33 | true 34 | Convert_Notes_to_ContentNotes_Log__c.Old_Note_ID__c 35 | true 36 | 37 | 38 | true 39 | Convert_Notes_to_ContentNotes_Log__c.Status__c 40 | true 41 | 42 | 43 | true 44 | Convert_Notes_to_ContentNotes_Log__c.Summary__c 45 | true 46 | 47 | false 48 | 49 | Salesforce 50 | 51 | true 52 | true 53 | true 54 | true 55 | true 56 | Convert_Notes_to_ContentNotes_Log__c 57 | true 58 | 59 | 60 | ConvertNotesFAQPage 61 | true 62 | 63 | 64 | ConvertNotesMenuPage 65 | true 66 | 67 | 68 | ConvertNotesRunOncePage 69 | true 70 | 71 | 72 | ConvertNotesSchedulePage 73 | true 74 | 75 | 76 | ConvertNotesSettingsPage 77 | true 78 | 79 | 80 | Convert_Notes_to_ContentNotes 81 | Visible 82 | 83 | 84 | Convert_Notes_to_ContentNotes_Log__c 85 | Visible 86 | 87 | 88 | true 89 | CreateAuditFields 90 | 91 | 92 | true 93 | UpdateWithInactiveOwner 94 | 95 | 96 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesQueueable.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | * 4 | * Designed to be invoked within a trigger or some other scenario where you 5 | * need exactly one batch of records (up to 200 records) to be converted to ContentNotes. 6 | * 7 | * Actual conversion for the execution is delegated to ConvertNotesToContentNotesService. 8 | * 9 | * We specify 'without sharing' so that when 'Public Site Guest' users on Force.com Sites 10 | * create a Note and the Trigger "auto converts" to Enhanced Note by enqueueing this class 11 | * then the notes query in this class will actually find results. 12 | * When we specify 'with sharing' then since the 'Public Site Guest' user may not actually 13 | * have record level access to the parent entity and thus the note then no records come back 14 | * and downstream conversion fails. 15 | */ 16 | global without sharing class ConvertNotesToContentNotesQueueable implements System.Queueable { 17 | 18 | @TestVisible 19 | private List mockResults { get; set; } 20 | 21 | @TestVisible 22 | private Exception mockException { get; set; } 23 | 24 | // ---------------------------------------------------------------------- 25 | 26 | private ConvertNotesToContentNotesOptions options { get; set; } 27 | 28 | private Set noteIds { get; set; } 29 | 30 | // if context user is a community user then we 31 | // need to pass on the network id to assign to ContentVersion 32 | private ID networkId { get; set; } 33 | 34 | global ConvertNotesToContentNotesQueueable( Set noteIds ) { 35 | this( noteIds, new ConvertNotesToContentNotesOptions() ); 36 | } 37 | 38 | // not exposed to subscriber orgs, want users to configure the custom setting 39 | public ConvertNotesToContentNotesQueueable( Set noteIds, ConvertNotesToContentNotesOptions options ) { 40 | this( noteIds, options, Network.getNetworkId() ); 41 | } 42 | 43 | // not exposed to subscriber orgs, want users to configure the custom setting 44 | public ConvertNotesToContentNotesQueueable( Set noteIds, ConvertNotesToContentNotesOptions options, ID networkId ) { 45 | this.noteIds = noteIds; 46 | this.options = options; 47 | this.networkId = networkId; 48 | } 49 | 50 | // ---------------------------------------------------------------------- 51 | 52 | public void execute( QueueableContext context ) { 53 | 54 | SavePoint sp = Database.setSavePoint(); 55 | 56 | DateTime startTime = DateTime.now(); 57 | 58 | try { 59 | 60 | System.debug( '[ConvertNotesToContentNotesQueueable.execute] Executing: ' + context ); 61 | 62 | List notes = new List([ 63 | SELECT 64 | id, parentId, ownerId, title, body, isPrivate, 65 | createdById, createdDate, lastModifiedById, lastModifiedDate 66 | FROM 67 | Note 68 | WHERE 69 | id IN :this.noteIds 70 | ORDER BY 71 | parentId 72 | ]); 73 | 74 | ConvertNotesToContentNotesService service = new ConvertNotesToContentNotesService( this.options, this.networkId ); 75 | 76 | List results = service.convert( notes ); 77 | 78 | if ( Test.isRunningTest() ) { 79 | 80 | if ( this.mockException != null ) { 81 | throw this.mockException; 82 | } 83 | 84 | if ( this.mockResults != null ) { 85 | results = mockResults; 86 | } 87 | 88 | } 89 | 90 | ConvertNotesToContentNotesLogger.log( context.getJobId(), results ); 91 | 92 | } catch ( Exception e ) { 93 | 94 | Database.rollback( sp ); 95 | 96 | ConvertNotesToContentNotesLogger.log( context.getJobId(), e ); 97 | 98 | } finally { 99 | 100 | ConvertNotesToContentNotesLogger.sendApexExceptionEmailIfAnyErrorsSince( startTime ); 101 | 102 | } 103 | 104 | } 105 | 106 | } -------------------------------------------------------------------------------- /src/objects/Convert_Notes_to_ContentNotes_Settings__c.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hierarchy 4 | Controls when and how certain notes are converted to enhanced notes. 5 | false 6 | 7 | Convert_Private_Notes__c 8 | false 9 | false 10 | Deprecated since version 1.3. Do not use. 11 | false 12 | Deprecated since version 1.3. Do not use. 13 | 14 | false 15 | Checkbox 16 | 17 | 18 | Convert_in_Near_Real_Time__c 19 | false 20 | false 21 | Enables 'after insert' trigger on Note that enqueues job to convert into ContentNotes in near real-time. This async conversion does not slow down save transaction, and if 'Delete Notes' option is enabled then deletes in separate transaction too. 22 | false 23 | Enables 'after insert' trigger on Note that enqueues job to convert into ContentNotes in near real-time. This async conversion does not slow down save transaction, and if 'Delete Notes' option is enabled then deletes in separate transaction too. 24 | 25 | false 26 | Checkbox 27 | 28 | 29 | Delete_Note_Once_Converted__c 30 | false 31 | false 32 | When checked then conversion process will delete the original Note once successfully converted to ContentNote. You may want to delete them to save storage space in your org. 33 | false 34 | When checked then conversion process will delete the original Note once successfully converted to ContentNote. You may want to delete them to save storage space in your org. 35 | 36 | false 37 | Checkbox 38 | 39 | 40 | Share_Private_Notes__c 41 | false 42 | false 43 | Deprecated since version 1.3. Do not use. 44 | false 45 | Deprecated since version 1.3. Do not use. 46 | 47 | false 48 | Checkbox 49 | 50 | 51 | Share_Type__c 52 | "V" 53 | false 54 | Deprecated since version 1.2. Do not use. 55 | false 56 | Deprecated since version 1.2. Do not use. 57 | 58 | 255 59 | false 60 | false 61 | Text 62 | false 63 | 64 | 65 | Visibility__c 66 | "InternalUsers" 67 | false 68 | Deprecated since version 1.2. Do not use. 69 | false 70 | Deprecated since version 1.2. Do not use. 71 | 72 | 255 73 | false 74 | false 75 | Text 76 | false 77 | 78 | 79 | Public 80 | 81 | -------------------------------------------------------------------------------- /src/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ConvertNotesInstallHandler 5 | ConvertNotesInstallHandlerTest 6 | ConvertNotesRunOnceController 7 | ConvertNotesRunOnceControllerTest 8 | ConvertNotesScheduleController 9 | ConvertNotesScheduleControllerTest 10 | ConvertNotesSettingsController 11 | ConvertNotesSettingsControllerTest 12 | ConvertNotesToContentNotesBatchable 13 | ConvertNotesToContentNotesBatchableTest 14 | ConvertNotesToContentNotesLogger 15 | ConvertNotesToContentNotesOptions 16 | ConvertNotesToContentNotesQueueable 17 | ConvertNotesToContentNotesQueueableTest 18 | ConvertNotesToContentNotesSchedulable 19 | ConvertNotesToContentNotesScheduleTest 20 | ConvertNotesToContentNotesService 21 | ConvertNotesToContentNotesServiceTest 22 | ConvertNotesToContentNotesTestFactory 23 | ApexClass 24 | 25 | 26 | ConvertNotesFAQPage 27 | ConvertNotesMenuPage 28 | ConvertNotesRunOncePage 29 | ConvertNotesSchedulePage 30 | ConvertNotesSettingsPage 31 | ApexPage 32 | 33 | 34 | ConvertNotesToContentNotesTrigger 35 | ApexTrigger 36 | 37 | 38 | Convert_Notes_to_ContentNotes_Log__c.Default_Compact_Layout 39 | CompactLayout 40 | 41 | 42 | Convert_Notes_to_ContentNotes 43 | Convert_Notes_to_ContentNotes_Lightning 44 | CustomApplication 45 | 46 | 47 | ContentVersion.Original_Record_ID__c 48 | ContentVersion.Original_Record_Owner_ID__c 49 | ContentVersion.Original_Record_Parent_ID__c 50 | Convert_Notes_to_ContentNotes_Log__c.Detail__c 51 | Convert_Notes_to_ContentNotes_Log__c.Job_ID__c 52 | Convert_Notes_to_ContentNotes_Log__c.Log_Time__c 53 | Convert_Notes_to_ContentNotes_Log__c.New_Note_ID__c 54 | Convert_Notes_to_ContentNotes_Log__c.Old_Note_ID__c 55 | Convert_Notes_to_ContentNotes_Log__c.Status__c 56 | Convert_Notes_to_ContentNotes_Log__c.Summary__c 57 | Convert_Notes_to_ContentNotes_Settings__c.Convert_Private_Notes__c 58 | Convert_Notes_to_ContentNotes_Settings__c.Convert_in_Near_Real_Time__c 59 | Convert_Notes_to_ContentNotes_Settings__c.Delete_Note_Once_Converted__c 60 | Convert_Notes_to_ContentNotes_Settings__c.Share_Private_Notes__c 61 | Convert_Notes_to_ContentNotes_Settings__c.Share_Type__c 62 | Convert_Notes_to_ContentNotes_Settings__c.Visibility__c 63 | CustomField 64 | 65 | 66 | Convert_Notes_to_ContentNotes_Log__c 67 | Convert_Notes_to_ContentNotes_Settings__c 68 | CustomObject 69 | 70 | 71 | Convert_Notes_to_ContentNotes 72 | Convert_Notes_to_ContentNotes_Log__c 73 | CustomTab 74 | 75 | 76 | Convert_Notes_to_Enhanced_Notes_Log_Record_Page 77 | FlexiPage 78 | 79 | 80 | Convert_Notes_to_ContentNotes_Log__c-Convert Notes to Enhanced Notes Log Layout 81 | Layout 82 | 83 | 84 | Convert_Notes_to_ContentNotes_Log__c.All 85 | Convert_Notes_to_ContentNotes_Log__c.Converted 86 | Convert_Notes_to_ContentNotes_Log__c.Error 87 | Convert_Notes_to_ContentNotes_Log__c.Skipped 88 | ListView 89 | 90 | 91 | Convert_Notes_to_ContentNotes 92 | PermissionSet 93 | 94 | 41.0 95 | 96 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesBatchable.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | * 4 | * Designed for mass converting all notes in system or just those 5 | * belonging to specific parent records as indicated in the configuration options. 6 | * 7 | * Actual conversion for each batch execution is delegated to ConvertNotesToContentNotesService. 8 | */ 9 | global without sharing class ConvertNotesToContentNotesBatchable implements Database.Batchable, Database.Stateful { 10 | 11 | @TestVisible 12 | private List mockResults { get; set; } 13 | 14 | @TestVisible 15 | private Exception mockException { get; set; } 16 | 17 | // ---------------------------------------------------------- 18 | 19 | @TestVisible 20 | private ConvertNotesToContentNotesOptions options { get; set; } 21 | 22 | @TestVisible 23 | private Integer conversionCount { get; set; } // the number of notes converted by this batchable 24 | 25 | global ConvertNotesToContentNotesBatchable() { 26 | this( new ConvertNotesToContentNotesOptions() ); 27 | } 28 | 29 | // not exposed to subscriber orgs, want users to configure the custom setting 30 | public ConvertNotesToContentNotesBatchable( ConvertNotesToContentNotesOptions options ) { 31 | this.options = options; 32 | this.conversionCount = 0; 33 | } 34 | 35 | // ---------------------------------------------------------- 36 | 37 | public Database.QueryLocator start( Database.BatchableContext context ) { 38 | 39 | System.debug( '[ConvertNotesToContentNotesBatchable.start] Starting: ' + context ); 40 | System.debug( '[ConvertNotesToContentNotesBatchable.start] Options: ' + this.options ); 41 | 42 | if ( this.options == null || this.options.parentIds == null ) { 43 | 44 | return Database.getQueryLocator([ 45 | SELECT 46 | id, ownerId, owner.isActive, parentId, title, body, isPrivate, 47 | createdById, createdDate, lastModifiedById, lastModifiedDate 48 | FROM 49 | Note 50 | ORDER BY 51 | parentId 52 | ]); 53 | 54 | } else { 55 | 56 | return Database.getQueryLocator([ 57 | SELECT 58 | id, ownerId, owner.isActive, parentId, title, body, isPrivate, 59 | createdById, createdDate, lastModifiedById, lastModifiedDate 60 | FROM 61 | Note 62 | WHERE 63 | parentId IN :this.options.parentIds 64 | ORDER BY 65 | parentId 66 | ]); 67 | 68 | } 69 | 70 | } 71 | 72 | public void execute( Database.BatchableContext context, List notes ) { 73 | 74 | if ( this.conversionCount >= this.options.maxRecordsToConvert ) { 75 | System.debug( 'Reached max records to convert; aborting job: ' + context ); 76 | System.abortJob( context.getJobId() ); 77 | return; 78 | } 79 | 80 | SavePoint sp = Database.setSavePoint(); 81 | 82 | try { 83 | 84 | System.debug( '[ConvertNotesToContentNotesBatchable.execute] Executing: ' + context ); 85 | System.debug( '[ConvertNotesToContentNotesBatchable.execute] Options: ' + this.options ); 86 | 87 | ConvertNotesToContentNotesService service = new ConvertNotesToContentNotesService( this.options ); 88 | 89 | List results = service.convert( notes ); 90 | 91 | if ( Test.isRunningTest() ) { 92 | 93 | if ( this.mockException != null ) { 94 | throw this.mockException; 95 | } 96 | 97 | if ( this.mockResults != null ) { 98 | results = mockResults; 99 | } 100 | 101 | } 102 | 103 | ConvertNotesToContentNotesLogger.log( context.getJobId(), results ); 104 | 105 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 106 | if ( result.status == ConvertNotesToContentNotesService.ConversionResultStatus.CONVERTED ) { 107 | this.conversionCount++; 108 | } 109 | } 110 | 111 | } catch ( Exception e ) { 112 | 113 | Database.rollback( sp ); 114 | 115 | ConvertNotesToContentNotesLogger.log( context.getJobId(), e ); 116 | 117 | } 118 | 119 | } 120 | 121 | public void finish( Database.BatchableContext context ) { 122 | 123 | System.debug( '[ConvertNotesToContentNotesBatchable.finish] Finishing: ' + context ); 124 | System.debug( '[ConvertNotesToContentNotesBatchable.finish] Options: ' + this.options ); 125 | 126 | ConvertNotesToContentNotesLogger.sendApexExceptionEmailIfAnyErrorsSince( context.getJobId() ); 127 | 128 | } 129 | 130 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesLogger.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | public without sharing class ConvertNotesToContentNotesLogger { 5 | 6 | /** 7 | * Designed to be called once when exception is caught 8 | * during a batch or queued job conversion. 9 | */ 10 | public static void log( ID jobId, Exception e ) { 11 | 12 | System.debug( LoggingLevel.ERROR, 'jobId: ' + jobId + ', error: ' + e.getMessage() + ' : ' + e.getStackTraceString() ); 13 | 14 | Database.DMLOptions dmo = new Database.DMLOptions(); 15 | dmo.allowFieldTruncation = true; 16 | 17 | Convert_Notes_to_ContentNotes_Log__c log = new Convert_Notes_to_ContentNotes_Log__c( 18 | job_id__c = jobId, 19 | status__c = 'ERROR', 20 | summary__c = e.getMessage(), 21 | detail__c = e.getStackTraceString() 22 | ); 23 | 24 | Database.insert( log, dmo ); 25 | 26 | } 27 | 28 | /** 29 | * Designed to be called after conversion job has run to log any errors. 30 | */ 31 | public static void log( ID jobId, List results ) { 32 | 33 | List logs = new List(); 34 | Integer maxLength = Convert_Notes_to_ContentNotes_Log__c.Summary__c.getDescribe().getLength(); 35 | 36 | Database.DMLOptions dmo = new Database.DMLOptions(); 37 | dmo.allowFieldTruncation = true; 38 | 39 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 40 | 41 | System.debug( 42 | getLoggingLevelFromSeverity( result.status ), 43 | 'jobId: ' + jobId + ', message: ' + result.message 44 | ); 45 | 46 | logs.add( new Convert_Notes_to_ContentNotes_Log__c( 47 | job_id__c = jobId, 48 | status__c = String.valueOf( result.status ), 49 | old_note_id__c = ( result.oldNote != null ? result.oldNote.id : null ), 50 | new_note_id__c = result.contentNoteId, 51 | summary__c = result.message.abbreviate( maxLength ), 52 | detail__c = result.message 53 | )); 54 | 55 | } 56 | 57 | if ( logs.size() > 0 ) { 58 | Database.insert( logs, dmo ); 59 | } 60 | 61 | } 62 | 63 | private static LoggingLevel getLoggingLevelFromSeverity( ConvertNotesToContentNotesService.ConversionResultStatus status ) { 64 | 65 | LoggingLevel level = LoggingLevel.DEBUG; 66 | 67 | if ( status == ConvertNotesToContentNotesService.ConversionResultStatus.SKIPPED ) { 68 | level = LoggingLevel.WARN; 69 | } else if ( status == ConvertNotesToContentNotesService.ConversionResultStatus.ERROR ) { 70 | level = LoggingLevel.ERROR; 71 | } 72 | 73 | return level; 74 | } 75 | 76 | // -------------------------------------------------------------------- 77 | 78 | /** 79 | * Checks if any log records have been created since the job started. 80 | * If yes then sends an email to any Salesforce users configured for Apex Exception Email. 81 | */ 82 | public static void sendApexExceptionEmailIfAnyErrorsSince( ID jobId ) { 83 | 84 | AsyncApexJob job = [ SELECT id, createdDate FROM AsyncApexJob WHERE id = :jobId ]; 85 | 86 | sendApexExceptionEmailIfAnyErrorsSince( job.createdDate ); 87 | 88 | } 89 | 90 | /** 91 | * Checks if any log records have been created since the given date/time. 92 | * If yes then sends an email to any Salesforce users configured for Apex Exception Email. 93 | */ 94 | public static void sendApexExceptionEmailIfAnyErrorsSince( DateTime sinceDateTime ) { 95 | 96 | Integer count = [ SELECT count() FROM Convert_Notes_to_ContentNotes_Log__c WHERE createdDate >= :sinceDateTime AND status__c = 'ERROR' ]; 97 | 98 | if ( count > 0 ) { 99 | 100 | List usersToNotify = new List(); 101 | 102 | for ( ApexEmailNotification notif : [ SELECT userId FROM ApexEmailNotification WHERE userId != null LIMIT 100 ] ) { 103 | usersToNotify.add( notif.userId ); 104 | } 105 | 106 | if ( usersToNotify.size() > 0 ) { 107 | 108 | Messaging.SingleEmailMessage message = new Messaging.SingleEmailMessage(); 109 | message.toAddresses = usersToNotify; 110 | message.subject = 'Errors: Convert Notes to Enhanced Notes'; 111 | message.plainTextBody = 'Errors have occurred. Please review the log records for more details ' + 112 | URL.getSalesforceBaseURL().toExternalForm() + '/' + Convert_Notes_to_ContentNotes_Log__c.sObjectType.getDescribe().getKeyPrefix(); 113 | 114 | Messaging.sendEmail( new Messaging.SingleEmailMessage[] { message } ); 115 | 116 | } 117 | 118 | } 119 | 120 | } 121 | 122 | } -------------------------------------------------------------------------------- /src/pages/ConvertNotesMenuPage.page: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 |
18 | 19 |
20 | 21 |
22 | 27 |

28 | Convert Notes to Enhanced Notes 29 |

30 |
31 | 32 |
33 | 34 | 70 | 71 |
72 | 73 | 87 | 88 |
89 | 90 |
91 | 98 |
99 | 100 |
101 | 102 |
103 |

104 | © 2017 Douglas C. Ayers. 105 | View project on GitHub. 106 | Licensed under BSD-3 License. 107 |

108 |
109 | 110 |
111 | 112 |
113 | 114 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesRunOnceControllerTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesRunOnceControllerTest { 6 | 7 | @isTest 8 | static void test_convert_all() { 9 | 10 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 11 | settings.Convert_in_Near_Real_Time__c = false; 12 | settings.Delete_Note_Once_Converted__c = true; 13 | 14 | upsert settings; 15 | 16 | Account acct1 = new Account( 17 | name = 'Test Account 1' 18 | ); 19 | 20 | insert acct1; 21 | 22 | Account acct2 = new Account( 23 | name = 'Test Account 2' 24 | ); 25 | 26 | insert acct2; 27 | 28 | Note note1 = new Note( 29 | title = 'Hello World 1.txt', 30 | body = 'Hello World 1', 31 | parentId = acct1.id 32 | ); 33 | 34 | insert note1; 35 | 36 | Note note2 = new Note( 37 | title = 'Hello World 2.txt', 38 | body = 'Hello World 2', 39 | parentId = acct2.id 40 | ); 41 | 42 | insert note2; 43 | 44 | Test.startTest(); 45 | 46 | ConvertNotesRunOnceController controller = new ConvertNotesRunOnceController(); 47 | 48 | controller.submitJob(); 49 | 50 | Test.stopTest(); 51 | 52 | System.assertEquals( true, controller.success ); 53 | System.assert( controller.message.containsIgnoreCase( 'Conversion batch job submitted' ) ); 54 | 55 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id AND contentDocument.latestPublishedVersion.original_record_id__c = :note1.id ] ); 56 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct2.id AND contentDocument.latestPublishedVersion.original_record_id__c = :note2.id ] ); 57 | 58 | } 59 | 60 | @isTest 61 | static void test_convert_one() { 62 | 63 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 64 | settings.Convert_in_Near_Real_Time__c = false; 65 | settings.Delete_Note_Once_Converted__c = true; 66 | 67 | upsert settings; 68 | 69 | Account acct1 = new Account( 70 | name = 'Test Account 1' 71 | ); 72 | 73 | insert acct1; 74 | 75 | Account acct2 = new Account( 76 | name = 'Test Account 2' 77 | ); 78 | 79 | insert acct2; 80 | 81 | Note note1 = new Note( 82 | title = 'Hello World 1.txt', 83 | body = 'Hello World 1', 84 | parentId = acct1.id 85 | ); 86 | 87 | insert note1; 88 | 89 | Note note2 = new Note( 90 | title = 'Hello World 2.txt', 91 | body = 'Hello World 2', 92 | parentId = acct2.id 93 | ); 94 | 95 | insert note2; 96 | 97 | Test.startTest(); 98 | 99 | ConvertNotesRunOnceController controller = new ConvertNotesRunOnceController(); 100 | controller.parentIdsCsv = acct1.id + ',' + acct1.id; // same id, comma separated to test split 101 | 102 | controller.submitJob(); 103 | 104 | Test.stopTest(); 105 | 106 | System.assertEquals( true, controller.success ); 107 | System.assert( controller.message.containsIgnoreCase( 'Conversion batch job submitted' ) ); 108 | 109 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id AND contentDocument.latestPublishedVersion.original_record_id__c = :note1.id ] ); 110 | System.assertEquals( 0, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct2.id AND contentDocument.latestPublishedVersion.original_record_id__c = :note2.id ] ); 111 | 112 | } 113 | 114 | @isTest 115 | static void test_convert_error() { 116 | 117 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 118 | settings.Convert_in_Near_Real_Time__c = false; 119 | settings.Delete_Note_Once_Converted__c = true; 120 | 121 | upsert settings; 122 | 123 | Account acct1 = new Account( 124 | name = 'Test Account 1' 125 | ); 126 | 127 | insert acct1; 128 | 129 | Account acct2 = new Account( 130 | name = 'Test Account 2' 131 | ); 132 | 133 | insert acct2; 134 | 135 | Note note1 = new Note( 136 | title = 'Hello World 1.txt', 137 | body = 'Hello World 1', 138 | parentId = acct1.id 139 | ); 140 | 141 | insert note1; 142 | 143 | Note note2 = new Note( 144 | title = 'Hello World 2.txt', 145 | body = 'Hello World 2', 146 | parentId = acct2.id 147 | ); 148 | 149 | insert note2; 150 | 151 | Test.startTest(); 152 | 153 | ConvertNotesRunOnceController controller = new ConvertNotesRunOnceController(); 154 | controller.batchSize = -10; // negative, should cause error 155 | 156 | controller.submitJob(); 157 | 158 | Test.stopTest(); 159 | 160 | System.assertEquals( false, controller.success ); 161 | 162 | System.assertEquals( 0, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id AND contentDocument.latestPublishedVersion.original_record_id__c = :note1.id ] ); 163 | System.assertEquals( 0, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct2.id AND contentDocument.latestPublishedVersion.original_record_id__c = :note2.id ] ); 164 | 165 | } 166 | 167 | } -------------------------------------------------------------------------------- /src/layouts/Convert_Notes_to_ContentNotes_Log__c-Convert Notes to Enhanced Notes Log Layout.layout: -------------------------------------------------------------------------------- 1 | 2 | 3 | ChangeOwnerOne 4 | ChangeRecordType 5 | Clone 6 | Share 7 | Submit 8 | 9 | false 10 | false 11 | true 12 | 13 | 14 | 15 | Readonly 16 | Name 17 | 18 | 19 | Readonly 20 | Job_ID__c 21 | 22 | 23 | Readonly 24 | Log_Time__c 25 | 26 | 27 | 28 | 29 | Readonly 30 | Status__c 31 | 32 | 33 | Readonly 34 | Old_Note_ID__c 35 | 36 | 37 | Readonly 38 | New_Note_ID__c 39 | 40 | 41 | 42 | 43 | 44 | true 45 | false 46 | false 47 | 48 | 49 | 50 | Readonly 51 | Summary__c 52 | 53 | 54 | Readonly 55 | Detail__c 56 | 57 | 58 | 59 | 60 | 61 | false 62 | true 63 | true 64 | 65 | 66 | 67 | Readonly 68 | CreatedById 69 | 70 | 71 | 72 | 73 | Readonly 74 | LastModifiedById 75 | 76 | 77 | 78 | 79 | 80 | true 81 | false 82 | true 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | Name 91 | Status__c 92 | Old_Note_ID__c 93 | New_Note_ID__c 94 | Summary__c 95 | Detail__c 96 | Log_Time__c 97 | 98 | 99 | Record 100 | 101 | FeedItem.TextPost 102 | QuickAction 103 | 0 104 | 105 | 106 | FeedItem.ContentPost 107 | QuickAction 108 | 1 109 | 110 | 111 | FeedItem.LinkPost 112 | QuickAction 113 | 2 114 | 115 | 116 | Edit 117 | StandardButton 118 | 3 119 | 120 | 121 | Delete 122 | StandardButton 123 | 4 124 | 125 | 126 | 127 | 128 | FeedItem.TextPost 129 | 130 | 131 | FeedItem.ContentPost 132 | 133 | 134 | FeedItem.MobileSmartActions 135 | 136 | 137 | FeedItem.LinkPost 138 | 139 | 140 | false 141 | false 142 | false 143 | false 144 | false 145 | 146 | 00h0a00000Bqsn1 147 | 4 148 | 0 149 | Default 150 | 151 | 152 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesSettingsPage.page: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 |
20 | 26 |

27 | Convert Notes to Enhanced Notes 28 |

29 |
30 | 31 |
32 | 33 |
34 |

35 | ContentNotes, as compared to old notes object, have more powerful sharing options; let you use rich text, lists and images; and relate notes to multiple records. 36 |

37 |

38 | Please take a moment to carefully review your conversion sharing options below. 39 |

40 |
41 | 42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 |
50 |
51 | 61 |
62 |
63 |
64 | 65 | 66 | 67 |
68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 80 | 81 | 83 | 84 | 85 | 86 |
87 | 95 |
96 | 97 |
98 | 99 |
100 | 101 |
102 | 103 |
104 |
105 | 106 | 107 |
108 |
109 | 110 | 122 | 123 |
124 | 125 |
126 | 127 |
128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /src/pages/ConvertNotesSchedulePage.page: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 |
22 | 23 |
24 | 25 |
26 | 32 |

33 | Convert Notes to Enhanced Notes 34 |

35 |
36 | 37 |
38 | 39 |
40 |

41 | Schedule how often new notes should be converted to enhanced notes. 42 |

43 |

44 | This may be necessary because notes might still be created after initial conversion for various reasons, 45 | such as from user uploads or integrations. 46 |

47 |
48 | 49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 | 68 |
69 |
70 |
71 | 72 | 73 | 74 |
75 | 76 |

77 | Enable Note Trigger 78 |

79 | 80 |

81 | If you have integrations that create new notes, this trigger will submit a background job to convert them to enhanced notes as they are inserted. 82 |

83 | 84 |

85 | Please carefully review your conversion and sharing settings before enabling this option. 86 |

87 | 88 |

89 | 90 | 91 | 92 | 95 | 96 | 97 | 98 |

99 | 100 |
101 |
102 | 103 |
104 |
105 | 106 |
107 | 108 |
109 | 110 |

111 | Schedule Job 112 |

113 | 114 |

115 | Schedule a recurring job to convert new notes to enhanced notes. 116 |
117 | You might consider this option if you have scenarios where new notes are created but are not causing the trigger to fire. 118 |
119 | Click here to be taken to the Schedule Job page in Setup, or follow below directions. 120 |

121 | 122 |

123 |

    124 |
  1. 125 | From Setup, enter Apex Classes in the Quick Find box, then select Schedule Apex button. 126 |
  2. 127 |
  3. 128 | Specify Apex Class "ConvertNotesToContentNotesSchedulable" and your desired schedule. 129 |
  4. 130 |
131 |

132 | 133 |

134 | Please carefully review your conversion and sharing settings before enabling this option. 135 |

136 | 137 |
138 | 145 |
146 | 147 |
148 | 149 |
150 | 151 | 163 | 164 |
165 | 166 |
167 | 168 |
169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesQueueableTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesToContentNotesQueueableTest { 6 | 7 | @isTest 8 | static void test_queueable_with_options() { 9 | 10 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 11 | settings.Convert_in_Near_Real_Time__c = false; 12 | settings.Delete_Note_Once_Converted__c = true; 13 | 14 | upsert settings; 15 | 16 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 17 | 18 | User user1, user2; 19 | 20 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 21 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 22 | 23 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Alpha', 'User 1', 'user_1@example.com' ); 24 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Beta', 'User 2', 'user_2@example.com' ); 25 | 26 | insert new List{ user1, user2 }; 27 | 28 | } 29 | 30 | Account acct1 = new Account( 31 | ownerId = user1.id, 32 | name = 'Test Account' 33 | ); 34 | 35 | insert acct1; 36 | 37 | Note note1 = new Note( 38 | title = 'Hello World.txt', 39 | body = 'Goodnight Moon', 40 | parentId = acct1.id, 41 | ownerId = user1.id 42 | ); 43 | 44 | insert note1; 45 | 46 | Test.startTest(); 47 | 48 | ConvertNotesToContentNotesQueueable queueable = new ConvertNotesToContentNotesQueueable( 49 | new Set{ note1.id }, 50 | new ConvertNotesToContentNotesOptions( settings ) 51 | ); 52 | 53 | System.enqueueJob( queueable ); 54 | 55 | Test.stopTest(); 56 | 57 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 58 | System.debug( log ); 59 | } 60 | 61 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id ] ); 62 | System.assertEquals( 0, [ SELECT count() FROM Note WHERE id = :note1.id ] ); 63 | 64 | ContentDocumentLink cdl_note1 = [ 65 | SELECT 66 | id, 67 | contentDocumentId, 68 | contentDocument.fileType, 69 | contentDocument.latestPublishedVersion.original_record_id__c, 70 | contentDocument.latestPublishedVersion.original_record_parent_id__c, 71 | contentDocument.latestPublishedVersion.original_record_owner_id__c 72 | FROM 73 | ContentDocumentLink 74 | WHERE 75 | linkedEntityId = :acct1.id 76 | AND 77 | contentDocument.latestPublishedVersion.original_record_id__c = :note1.id 78 | ]; 79 | 80 | System.assertEquals( 'SNOTE', cdl_note1.contentDocument.fileType ); 81 | System.assertEquals( note1.id, cdl_note1.contentDocument.latestPublishedVersion.original_record_id__c ); 82 | System.assertEquals( note1.parentId, cdl_note1.contentDocument.latestPublishedVersion.original_record_parent_id__c ); 83 | System.assertEquals( note1.ownerId, cdl_note1.contentDocument.latestPublishedVersion.original_record_owner_id__c ); 84 | 85 | } 86 | 87 | @isTest 88 | static void test_queueable_without_options() { 89 | 90 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 91 | settings.Convert_in_Near_Real_Time__c = false; 92 | settings.Delete_Note_Once_Converted__c = false; 93 | 94 | upsert settings; 95 | 96 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 97 | 98 | User user1, user2; 99 | 100 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 101 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 102 | 103 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Alpha', 'User 1', 'user_1@example.com' ); 104 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Beta', 'User 2', 'user_2@example.com' ); 105 | 106 | insert new List{ user1, user2 }; 107 | 108 | } 109 | 110 | Account acct1 = new Account( 111 | ownerId = user1.id, 112 | name = 'Test Account' 113 | ); 114 | 115 | insert acct1; 116 | 117 | Note note1 = new Note( 118 | title = 'Hello World.txt', 119 | body = 'Goodnight Moon', 120 | parentId = acct1.id, 121 | ownerId = user1.id 122 | ); 123 | 124 | insert note1; 125 | 126 | Test.startTest(); 127 | 128 | ConvertNotesToContentNotesQueueable queueable = new ConvertNotesToContentNotesQueueable( 129 | new Set{ note1.id } 130 | ); 131 | 132 | System.enqueueJob( queueable ); 133 | 134 | Test.stopTest(); 135 | 136 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 137 | System.debug( log ); 138 | } 139 | 140 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id ] ); 141 | System.assertEquals( 1, [ SELECT count() FROM Note WHERE id = :note1.id ] ); 142 | 143 | ContentDocumentLink cdl_note1 = [ 144 | SELECT 145 | id, 146 | contentDocumentId, 147 | contentDocument.fileType, 148 | contentDocument.latestPublishedVersion.original_record_id__c, 149 | contentDocument.latestPublishedVersion.original_record_parent_id__c, 150 | contentDocument.latestPublishedVersion.original_record_owner_id__c 151 | FROM 152 | ContentDocumentLink 153 | WHERE 154 | linkedEntityId = :acct1.id 155 | AND 156 | contentDocument.latestPublishedVersion.original_record_id__c = :note1.id 157 | ]; 158 | 159 | System.assertEquals( 'SNOTE', cdl_note1.contentDocument.fileType ); 160 | System.assertEquals( note1.id, cdl_note1.contentDocument.latestPublishedVersion.original_record_id__c ); 161 | System.assertEquals( note1.parentId, cdl_note1.contentDocument.latestPublishedVersion.original_record_parent_id__c ); 162 | System.assertEquals( note1.ownerId, cdl_note1.contentDocument.latestPublishedVersion.original_record_owner_id__c ); 163 | 164 | } 165 | 166 | @isTest 167 | static void test_conversion_error() { 168 | 169 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 170 | settings.Convert_in_Near_Real_Time__c = false; 171 | settings.Delete_Note_Once_Converted__c = true; 172 | 173 | upsert settings; 174 | 175 | Account acct1 = new Account( 176 | name = 'Test Account' 177 | ); 178 | 179 | insert acct1; 180 | 181 | Note note1 = new Note( 182 | title = 'Hello World 1', 183 | body = 'Hello World 1', 184 | parentId = acct1.id 185 | ); 186 | 187 | insert note1; 188 | 189 | Test.startTest(); 190 | 191 | ConvertNotesToContentNotesService.ConversionResult mockResult = new ConvertNotesToContentNotesService.ConversionResult(); 192 | mockResult.status = ConvertNotesToContentNotesService.ConversionResultStatus.ERROR; 193 | mockResult.message = 'Mock Error Result'; 194 | mockResult.oldNote = note1; 195 | 196 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 197 | 198 | ConvertNotesToContentNotesQueueable queueable = new ConvertNotesToContentNotesQueueable( 199 | new Set{ note1.id } 200 | ); 201 | 202 | queueable.mockResults = new List{ mockResult }; 203 | 204 | System.enqueueJob( queueable ); 205 | 206 | Test.stopTest(); 207 | 208 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 209 | System.debug( log ); 210 | } 211 | 212 | System.assertEquals( 1, [ SELECT count() FROM Convert_Notes_to_ContentNotes_Log__c WHERE old_note_id__c = :note1.id AND status__c = 'ERROR' ] ); 213 | 214 | } 215 | 216 | @isTest 217 | static void test_conversion_exception() { 218 | 219 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 220 | settings.Convert_in_Near_Real_Time__c = false; 221 | settings.Delete_Note_Once_Converted__c = true; 222 | 223 | upsert settings; 224 | 225 | Account acct1 = new Account( 226 | name = 'Test Account' 227 | ); 228 | 229 | insert acct1; 230 | 231 | Note note1 = new Note( 232 | title = 'Hello World 1', 233 | body = 'Hello World 1', 234 | parentId = acct1.id 235 | ); 236 | 237 | insert note1; 238 | 239 | Test.startTest(); 240 | 241 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 242 | 243 | ConvertNotesToContentNotesQueueable queueable = new ConvertNotesToContentNotesQueueable( 244 | new Set{ note1.id } 245 | ); 246 | 247 | queueable.mockException = new System.NullPointerException(); 248 | 249 | System.enqueueJob( queueable ); 250 | 251 | Test.stopTest(); 252 | 253 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 254 | System.debug( log ); 255 | } 256 | 257 | System.assertEquals( 0, [ SELECT count() FROM ContentVersion WHERE original_record_id__c = :note1.id ] ); 258 | System.assertEquals( 0, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id ] ); 259 | System.assertEquals( 1, [ SELECT count() FROM Convert_Notes_to_ContentNotes_Log__c WHERE status__c = 'ERROR' ] ); 260 | 261 | } 262 | 263 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesService.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | * 4 | * Work horse that does the actual note to content note conversion. 5 | * 6 | * Uses 'without sharing' to ensure can perform SOQL queries on 7 | * existing ContentVersions and ContentDocumentLinks to know if 8 | * a Note has already been converted or not. 9 | */ 10 | public without sharing class ConvertNotesToContentNotesService { 11 | 12 | private ConvertNotesToContentNotesOptions options { get; set; } 13 | 14 | // if context user is a community user then we 15 | // need to pass on the network id to assign to ContentVersion 16 | private ID networkId { get; set; } 17 | 18 | public ConvertNotesToContentNotesService() { 19 | this( new ConvertNotesToContentNotesOptions() ); 20 | } 21 | 22 | public ConvertNotesToContentNotesService( ConvertNotesToContentNotesOptions options ) { 23 | this.options = options; 24 | } 25 | 26 | public ConvertNotesToContentNotesService( ConvertNotesToContentNotesOptions options, ID networkId ) { 27 | this.options = options; 28 | this.networkId = networkId; 29 | } 30 | 31 | /** 32 | * Each note record should have these fields populated: 33 | * - Id 34 | * - ParentId 35 | * - OwnerId 36 | * - Title 37 | * - Body 38 | * - IsPrivate 39 | * - CreatedById 40 | * - CreatedDate 41 | * - LastModifiedById 42 | * - LastModifiedDate 43 | */ 44 | public List convert( List oldNotes ) { 45 | 46 | // determine if communities are enabled and if so then we will need 47 | // to assign the network id field when inserting the content versions 48 | // otherwise error "INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY" occurs 49 | // if community user creates a note and it tries to get converted 50 | Boolean communitiesEnabled = ContentVersion.sObjectType.getDescribe().fields.getMap().containsKey( 'NetworkId' ); 51 | 52 | // identify if any of these notes have already been converted 53 | Map alreadyConvertedNoteIdsMap = getAlreadyConvertedNoteIdsMap( oldNotes ); 54 | 55 | Map oldNotesMap = new Map( oldNotes ); 56 | 57 | // map of old note id to conversion result 58 | Map conversionResultsMap = new Map(); 59 | 60 | // the new notes to try and save 61 | List newNoteVersions = new List(); 62 | 63 | for ( Note oldNote : oldNotes ) { 64 | 65 | // skip if we've already converted this record before 66 | if ( alreadyConvertedNoteIdsMap.containsKey( oldNote.id ) ) { 67 | 68 | ConversionResult conversionResult = new ConversionResult(); 69 | conversionResult.status = ConversionResultStatus.SKIPPED; 70 | conversionResult.oldNote = oldNote; 71 | conversionResult.contentNoteId = alreadyConvertedNoteIdsMap.get( oldNote.id ); 72 | conversionResult.message = 'Already converted.'; 73 | 74 | conversionResultsMap.put( oldNote.id, conversionResult ); 75 | 76 | continue; 77 | 78 | } 79 | 80 | // per Salesforce we must escape certain special characters 81 | // logic inspired by David Reed (http://www.ktema.org//2016/08/24/importing-notes-into-salesforce/) 82 | // https://help.salesforce.com/apex/HTViewSolution?id=000230867&language=en_US 83 | String noteBody = ( String.isBlank( oldNote.body ) ? '' : oldNote.body ) 84 | // the escape entity for '&' is '&' 85 | // so it includes '&' in its own escape sequence, which is a problem 86 | // because escapeXml() changes '&' to '&' as well 87 | // so a single '&' would become '&' 88 | // therefore we first find any normal '&' 89 | // and replace them with a token value that will 90 | // be later replaced with '&' 91 | .replace('&', 'sfdcAMPERSANDsfdc') 92 | .escapeXml() 93 | .replace('sfdcAMPERSANDsfdc', '&') 94 | // handle nitpick on apostrophe html entity 95 | .replace(''', ''') 96 | // handle known unsupported non-ascii characters 97 | // oddly, other symbols like ® ™ are ok unescaped 98 | .replace('©', '©') 99 | // handle new lines 100 | .replace('\r\n', '
') 101 | .replace('\r', '
') 102 | .replace('\n', '
') 103 | ; 104 | 105 | // content version cannot have a null or empty string body 106 | // so set to empty paragraph which will appear as blank note. 107 | // we do this after escaping the original note body otherwise 108 | // the

tag would get escaped, doh! 109 | if ( String.isBlank( noteBody ) ) { 110 | noteBody = '

'; 111 | } 112 | 113 | // We set the owner of the new content note to be the 114 | // same as the note's owner because both fields 115 | // must have same value to insert the content note. 116 | // If they do not match then we get error: 117 | // "Documents in a user's private library must always be owned by that user." 118 | // The other reason to reference the old record's owner 119 | // is if the original creator is inactive and the admin 120 | // needs the new converted file to be owned by an active user. 121 | // The owner of records can be changed, the created by cannot. 122 | 123 | ContentVersion newNoteVersion = new ContentVersion( 124 | // data fields 125 | title = oldNote.title, 126 | versionData = Blob.valueOf( noteBody ), 127 | pathOnClient = oldNote.title + '.snote', 128 | firstPublishLocationId = oldNote.parentId, 129 | sharingPrivacy = ( oldNote.isPrivate ? 'P' : 'N' ), 130 | // custom fields for history tracking and conversion purposes 131 | original_record_id__c = oldNote.id, 132 | original_record_parent_id__c = oldNote.parentId, 133 | original_record_owner_id__c = oldNote.ownerId, 134 | // audit fields 135 | ownerId = oldNote.ownerId, // system requirement, owner and creator must be the same 136 | createdById = oldNote.ownerId, 137 | createdDate = oldNote.createdDate, 138 | lastModifiedById = oldNote.lastModifiedById, 139 | lastModifiedDate = oldNote.lastModifiedDate 140 | ); 141 | 142 | // if communities are enabled then assign network id 143 | if ( communitiesEnabled ) { 144 | newNoteVersion.put( 'NetworkId', this.networkId ); 145 | } 146 | 147 | newNoteVersions.add( newNoteVersion ); 148 | 149 | } 150 | 151 | if ( newNoteVersions.size() > 0 ) { 152 | 153 | SavePoint sp = Database.setSavepoint(); 154 | 155 | try { 156 | 157 | Database.DMLOptions dmo = new Database.DMLOptions(); 158 | dmo.optAllOrNone = false; 159 | 160 | List saveResults = Database.insert( newNoteVersions, dmo ); 161 | 162 | for ( Integer i = 0; i < saveResults.size(); i++ ) { 163 | 164 | Database.SaveResult saveResult = saveResults[i]; 165 | 166 | Note oldNote = oldNotesMap.get( newNoteVersions[i].original_record_id__c ); 167 | 168 | ConversionResult conversionResult = new ConversionResult(); 169 | conversionResult.status = ( saveResult.isSuccess() ? ConversionResultStatus.CONVERTED : ConversionResultStatus.ERROR ); 170 | conversionResult.contentNoteId = saveResult.getId(); 171 | conversionResult.oldNote = oldNote; 172 | 173 | if ( !saveResult.isSuccess() ) { 174 | 175 | List messages = new List(); 176 | 177 | for ( Database.Error err : saveResult.getErrors() ) { 178 | messages.add( err.getMessage() ); 179 | } 180 | 181 | conversionResult.message = String.join( messages, ' ' ); 182 | 183 | } 184 | 185 | conversionResultsMap.put( oldNote.id, conversionResult ); 186 | 187 | } 188 | 189 | postProcessConversionResults( conversionResultsMap ); 190 | 191 | } catch ( Exception e ) { 192 | 193 | Database.rollback( sp ); 194 | throw e; 195 | 196 | } 197 | 198 | } 199 | 200 | // sort map values in same order as notes parameter 201 | List conversionResults = new List(); 202 | for ( Note oldNote : oldNotes ) { 203 | conversionResults.add( conversionResultsMap.get( oldNote.id ) ); 204 | } 205 | 206 | return conversionResults; 207 | } 208 | 209 | private void postProcessConversionResults( Map conversionResultsMap ) { 210 | 211 | // should we delete the converted notes? 212 | if ( this.options.deleteNotesUponConversion ) { 213 | deleteConvertedNotes( conversionResultsMap ); 214 | } 215 | 216 | } 217 | 218 | // ----------------------------------------------------------------- 219 | 220 | private void deleteConvertedNotes( Map conversionResultsMap ) { 221 | 222 | List notesToDelete = new List(); 223 | 224 | for ( ConversionResult conversionResult : conversionResultsMap.values() ) { 225 | if ( conversionResult.status == ConversionResultStatus.CONVERTED ) { 226 | notesToDelete.add( conversionResult.oldNote ); 227 | } 228 | } 229 | 230 | if ( notesToDelete.size() > 0 ) { 231 | delete notesToDelete; 232 | } 233 | 234 | } 235 | 236 | /** 237 | * Given a list of notes then returns the submap of those 238 | * that have already been converted and their new note ids. 239 | */ 240 | public Map getAlreadyConvertedNoteIdsMap( List notes ) { 241 | 242 | // map of old note ids to new note ids 243 | Map convertedNoteIdsMap = new Map(); 244 | 245 | Set noteIds = new Set(); 246 | Set parentIds = new Set(); 247 | 248 | for ( Note note : notes ) { 249 | noteIds.add( note.id ); 250 | parentIds.add( note.parentId ); 251 | } 252 | 253 | for ( List links : [ 254 | SELECT 255 | contentDocument.latestPublishedVersionId, 256 | contentDocument.latestPublishedVersion.original_record_id__c 257 | FROM 258 | ContentDocumentLink 259 | WHERE 260 | linkedEntityId IN :parentIds 261 | AND 262 | contentDocument.latestPublishedVersion.original_record_id__c IN :noteIds 263 | ]) { 264 | 265 | for ( ContentDocumentLink link : links ) { 266 | 267 | if ( link.contentDocument != null && link.contentDocument.latestPublishedVersion != null ) { 268 | 269 | if ( noteIds.contains( link.contentDocument.latestPublishedVersion.original_record_id__c ) ) { 270 | convertedNoteIdsMap.put( link.contentDocument.latestPublishedVersion.original_record_id__c, link.contentDocument.latestPublishedVersionId ); 271 | } 272 | 273 | } 274 | 275 | } 276 | 277 | } 278 | 279 | return convertedNoteIdsMap; 280 | } 281 | 282 | public class ConversionResult { 283 | 284 | // was conversion success? skipped? error? 285 | public ConversionResultStatus status { get; set; } 286 | 287 | // the old note to convert 288 | public Note oldNote { get; set; } 289 | 290 | // id of the newly converted note if conversion success 291 | public ID contentNoteId { get; set; } 292 | 293 | // any pertinent message 294 | public String message { get; set; } 295 | 296 | public ConversionResult() { 297 | this.message = ''; 298 | } 299 | 300 | } 301 | 302 | public enum ConversionResultStatus { 303 | CONVERTED, SKIPPED, ERROR 304 | } 305 | 306 | } -------------------------------------------------------------------------------- /src/pages/ConvertNotesRunOncePage.page: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 |
20 | 26 |

27 | Convert Notes to Enhanced Notes 28 |

29 |
30 | 31 |
32 | 33 |
34 |

35 | Configure and submit a one-time conversion job. 36 | You may repeat this process as often as you need. 37 | Notes that have already been converted will be skipped so that duplicate enhanced notes are not created. 38 |

39 |
40 | 41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 |
49 |
50 | 60 |
61 |
62 |
63 | 64 | 65 | 66 |
67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 |
78 |

79 | You may test on a small set of records by specifying one or more note parent record ids, 80 | otherwise all notes will be converted. 81 |

82 |
83 | 84 |
85 | 86 |
87 | 88 |
89 | 90 | 91 | 92 | 94 | 95 | 97 | 98 |
99 |

100 | The maximum number of files and enhanced notes that can be published in a 24-hour period is 200,000. (documentation) 101 |

102 | This includes files and enhanced notes created from conversion or files and enhanced notes that users create. 103 |

104 | Salesforce's API does not let us know how many have been published within the 24-hour period 105 | so there is no easy way to check and avoid hitting the limit. 106 |

107 | To help mitigate hitting this limit you may provide a Max Records to Convert number to set your own limit and the batch job will stop converting notes to enhanced notes 108 | when either: 109 |

    110 |
  1. (a) The limit you provide is reached
  2. 111 |
  3. (b) There are no more notes to convert
  4. 112 |
  5. (c) The error "ContentPublication Limit exceeded" occurs
  6. 113 |
114 |
115 | If you do reach the max limit in a 24-hour period and get error ContentPublication Limit exceeded then you will need to wait 116 | some time until your quota restores. Unfortunately, Salesforce does not let us know how close you are to hitting the limit, only that you have reached it. 117 | Recommendation, try again another day to continue the conversion process of remaining notes. 118 |

119 | The count of converted notes is incremented after each Batch Size of notes are processed. 120 | So it is possible that as many as ( Max Records To Convert + Batch Size ) records get converted before the conversion job stops. 121 |

122 |
123 | 124 |
125 | 126 |
127 | 128 |
129 | 130 | 131 | 132 | 134 | 135 | 137 | 138 |
139 |

140 | Same purpose and reason why you might set the Batch Size when using Data Loader, 141 | you may want to increase or decrease this value (1 minimum, 200 maximum) for performance reasons. 142 |

143 | If you have other Apex Triggers on ContentVersion, ContentNote, or ContentDocumentLink objects that themselves 144 | perform SOQL queries or insert/update/delete records then that may risk hitting governor limits around 145 | max number of queries that can be made or DML statements. Recommendation, either disable the other Apex Triggers or reduce Batch Size. 146 |

147 |
148 | 149 |
150 | 151 |
152 | 153 |
154 | 155 |
156 |
157 | 158 | 159 |
160 |
161 | 162 | 183 |
184 | 185 | 186 | 187 | 229 | 230 |
231 | 232 |
233 | 234 |
235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /src/objects/Convert_Notes_to_ContentNotes_Log__c.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Accept 5 | Default 6 | 7 | 8 | CancelEdit 9 | Default 10 | 11 | 12 | Clone 13 | Default 14 | 15 | 16 | Delete 17 | Default 18 | 19 | 20 | Edit 21 | Default 22 | 23 | 24 | List 25 | Default 26 | 27 | 28 | New 29 | Default 30 | 31 | 32 | SaveEdit 33 | Default 34 | 35 | 36 | Tab 37 | Default 38 | 39 | 40 | View 41 | Action override created by Lightning App Builder during activation. 42 | Convert_Notes_to_Enhanced_Notes_Log_Record_Page 43 | Large 44 | false 45 | Flexipage 46 | 47 | false 48 | Default_Compact_Layout 49 | 50 | Default_Compact_Layout 51 | Name 52 | Status__c 53 | Old_Note_ID__c 54 | New_Note_ID__c 55 | Summary__c 56 | Log_Time__c 57 | 58 | 59 | Deployed 60 | false 61 | When deployed as a managed package then users cannot see errors logged to the Debug Log. This object stores any errors and messages the Adminsitrator may need to know about when troubleshooting conversion issues. You may delete these records after you have reviewed them. 62 | false 63 | true 64 | false 65 | false 66 | false 67 | true 68 | true 69 | true 70 | true 71 | 72 | Detail__c 73 | false 74 | false 75 | 76 | 40000 77 | false 78 | LongTextArea 79 | 10 80 | 81 | 82 | Job_ID__c 83 | false 84 | The Batchable or Queueable Job ID when this error occurred. 85 | false 86 | The Batchable or Queueable Job ID when this error occurred. 87 | 88 | 255 89 | false 90 | false 91 | Text 92 | false 93 | 94 | 95 | Log_Time__c 96 | false 97 | In List Views and Reports the standard CreatedDate and LastModifiedDate display only the "date" portion and not the "time". This formula field is workaround to to view the "date" and "time" together. 98 | false 99 | CreatedDate 100 | 101 | false 102 | false 103 | DateTime 104 | 105 | 106 | New_Note_ID__c 107 | false 108 | If the log message is about a specific conversion issue with a record then this is the Enhanced Note ID. 109 | false 110 | If the log message is about a specific conversion issue with a record then this is the Enhanced Note ID. 111 | 112 | 255 113 | false 114 | false 115 | Text 116 | false 117 | 118 | 119 | Old_Note_ID__c 120 | false 121 | If the log message is about a specific conversion issue with a record then this is the original Note ID. 122 | false 123 | If the log message is about a specific conversion issue with a record then this is the original Note ID. 124 | 125 | 255 126 | false 127 | false 128 | Text 129 | false 130 | 131 | 132 | Status__c 133 | false 134 | Indicates the status of log message, whether a note was successfully converted or perhaps that it was skipped during conversion or that it failed conversion. The API values match conversion result enum value in apex code. 135 | false 136 | Indicates the status of log message, whether a note was successfully converted or perhaps that it was skipped during conversion or that it failed conversion. 137 | 138 | false 139 | false 140 | Picklist 141 | 142 | true 143 | 144 | false 145 | 146 | CONVERTED 147 | true 148 | 149 | 150 | 151 | SKIPPED 152 | false 153 | 154 | 155 | 156 | ERROR 157 | false 158 | 159 | 160 | 161 | 162 | 163 | 164 | Summary__c 165 | false 166 | false 167 | 168 | 255 169 | false 170 | false 171 | Text 172 | false 173 | 174 | 175 | 176 | All 177 | NAME 178 | Status__c 179 | Summary__c 180 | Detail__c 181 | Log_Time__c 182 | Old_Note_ID__c 183 | New_Note_ID__c 184 | Job_ID__c 185 | Everything 186 | 187 | 188 | 189 | Converted 190 | NAME 191 | Status__c 192 | Summary__c 193 | Detail__c 194 | Log_Time__c 195 | Old_Note_ID__c 196 | New_Note_ID__c 197 | Job_ID__c 198 | Everything 199 | 200 | Status__c 201 | equals 202 | CONVERTED 203 | 204 | 205 | 206 | 207 | Error 208 | NAME 209 | Status__c 210 | Summary__c 211 | Detail__c 212 | Log_Time__c 213 | Old_Note_ID__c 214 | New_Note_ID__c 215 | Job_ID__c 216 | Everything 217 | 218 | Status__c 219 | equals 220 | ERROR 221 | 222 | 223 | 224 | 225 | Skipped 226 | NAME 227 | Status__c 228 | Summary__c 229 | Detail__c 230 | Log_Time__c 231 | Old_Note_ID__c 232 | New_Note_ID__c 233 | Job_ID__c 234 | Everything 235 | 236 | Status__c 237 | equals 238 | SKIPPED 239 | 240 | 241 | 242 | 243 | {000000} 244 | 245 | AutoNumber 246 | 247 | Convert Notes to Enhanced Notes Logs 248 | 249 | Status__c 250 | Old_Note_ID__c 251 | New_Note_ID__c 252 | Summary__c 253 | Detail__c 254 | Log_Time__c 255 | New 256 | ChangeOwner 257 | Accept 258 | Status__c 259 | Old_Note_ID__c 260 | New_Note_ID__c 261 | Summary__c 262 | Detail__c 263 | Log_Time__c 264 | Status__c 265 | Old_Note_ID__c 266 | New_Note_ID__c 267 | Summary__c 268 | Detail__c 269 | Log_Time__c 270 | Status__c 271 | Job_ID__c 272 | Status__c 273 | Old_Note_ID__c 274 | New_Note_ID__c 275 | Summary__c 276 | Detail__c 277 | Log_Time__c 278 | 279 | Private 280 | Public 281 | 282 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Convert Notes to Enhanced Notes 2 | =============================== 3 | 4 | Overview 5 | -------- 6 | 7 | In [Winter '16](https://releasenotes.docs.salesforce.com/en-us/winter16/release-notes/rn_mobile_salesforce1_otherfeat_notes_ga.htm) the new enhanced Notes tool became generally available, 8 | and with it introduced a new "Notes" related list separate from the classic "Notes & Attachments" related list. 9 | 10 | In [Spring '17](https://releasenotes.docs.salesforce.com/en-us/spring17/release-notes/rn_files_add_related_list_to_page_layouts.htm) Salesforce announced that after **Winter '18** 11 | the "Notes & Attachments" related list will no longer have an upload or attach button. Customers will be required to migrate to and adopt Salesforce Files. 12 | Although this change is specific to Attachments/Files, it is very clear that "Notes & Attachments" related list will eventually be retired in favor of the new **Files** and **Notes** related lists. 13 | 14 | At the time of this project, Salesforce has not (yet?) provided an official conversion tool from Notes to Enhanced Notes. 15 | 16 | This project enables the manual or automatic conversion of classic [Notes](https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_objects_note.htm) 17 | into [Enhanced Notes](https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_objects_contentnote.htm) 18 | to take advantage of more sophisticated features, like sharing, revisions, rich text, images, etc. 19 | 20 | The package includes visualforce pages that let you: 21 | * Configure sharing and conversion options 22 | * Run test conversions 23 | * Enable near real-time or scheduled conversions 24 | 25 | Additional Background: 26 | * [Adopting Files and Enhanced Notes](https://douglascayers.com/adopting-files-and-enhanced-notes-in-lightning-experience/) 27 | * [Setup Notes](https://help.salesforce.com/articleView?id=notes_admin_setup.htm) 28 | * [Considerations for Enabling Enhanced Notes](https://help.salesforce.com/articleView?id=000230837&type=1&language=en_US) 29 | * [ContentNote Documentation](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_contentnote.htm) 30 | 31 | 32 | Related Ideas 33 | ------------- 34 | * [Need to retain original createdDate and time on notes when importing](https://success.salesforce.com/ideaView?id=08730000000BrSsAAK) 35 | * [Ability to Default Sharing Settings on New Notes (Content Note)](https://success.salesforce.com/ideaView?id=0873A000000E2a6QAC) 36 | * [Add "New" Notes to Data Import Wizard with Parent Object Linking](https://success.salesforce.com/ideaView?id=08730000000E1cEAAS) 37 | 38 | 39 | Pre-Requisites 40 | -------------- 41 | 42 | * Summer '17 (API 40.0) or later 43 | 44 | * Enable [Create Audit Fields](https://help.salesforce.com/articleView?id=000213290&type=1&language=en_US) so Note create/update/owner fields can be preserved on the new enhanced notes 45 | 46 | ![screen shot](images/setup-enable-create-audit-fields1.png) 47 | 48 | * Enable [New Notes](https://help.salesforce.com/articleView?id=notes_admin_setup.htm) so ContentNote object exists and the new note-taking tool is available 49 | 50 | ![screen shot](images/setup-enable-notes.png) 51 | 52 | Packaged Release History 53 | ------------------------ 54 | 55 | Release 1.4 (current) 56 | ------------- 57 | * Install package 58 | * [Production URL](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tf4000001URw5) 59 | * [Sandbox URL](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tf4000001URw5) 60 | * Adds support for "Guest Site" users creating notes when the near real-time trigger option is enabled. 61 | * Adds option to specify "Max Records to Convert" for customers who have more notes to convert than the daily allowed Content Publication Limit. This allows those admins to better plan multi-day conversions. [(Issue 1)](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/1) 62 | * Updated all code to API v41.0 (Winter '18) 63 | 64 | Release 1.3 65 | ----------- 66 | * New with Winter '18, **private** notes are converted and shared to parent record using new [File Privacy on Records](https://releasenotes.docs.salesforce.com/en-us/winter18/release-notes/rn_files_on_records.htm) field. [(Issue 21)](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/21) 67 | * Fixes issue where empty classic note body appears as `

` in converted enhanced note. [(Issue 19)](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/19) 68 | 69 | Release 1.2 70 | ----------- 71 | * Improved special character handling, partially resolves [Salesforce System Error](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/14) and [Try to decipher System Error Codes](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/11). 72 | * Preserves `LastModifiedDate` from original note. 73 | 74 | Please note, this app always preserved the original `CreatedDate` of the converted note but the `LastModifiedDate` was always "today" 75 | because of how the new Enhanced Note was being shared to the parent record. In my original design I had opted to give you all more options around how 76 | the newly converted Enhanced Note gets shared to internal and community users as well as whether users with access to the parent entity inherited 77 | read-only or edit access to the file. But those options came at the cost of never being able to preserve the original `LastModifiedDate`. 78 | 79 | Responding to your all's feedback and use cases, this version of the app now preserves the `LastModifiedDate` of the original note! 80 | However, to make this possible means the app no longer manually shares the new Enhanced Note by explicitly creating `ContentDocumentLink` records. 81 | This has the side effect that two previously available conversion settings have now been removed: 82 | * **Which community of users who have access to the note's parent record get access to the converted notes?** 83 | * **How should view or edit access to the converted note be granted to users with access to the note's parent record?** 84 | 85 | These options now rely on the Salesforce defaults. 86 | 87 | Release 1.1 88 | ----------- 89 | * Adds [Ability to Report on Conversions](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/7) 90 | * Adds [Schedulable, Batchable, Queueable classes now 'global'](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/8) 91 | * Fixes [Tests fail if no Partner Community enabled in org](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/issues/6) 92 | 93 | Release 1.0 94 | ----------- 95 | * Initial release 96 | 97 | Installing the Source Code (Developers) 98 | --------------------------------------- 99 | 100 | You may install the unmanaged code from GitHub and make any desired adjustments. You are responsible for ensuring unit tests meet your org's validation rules and other requirements. 101 | 102 | * [Deploy from Github](https://githubsfdeploy.herokuapp.com) 103 | 104 | 105 | Getting Started 106 | --------------- 107 | 108 | 1. Enable setting [Create Audit Fields](https://help.salesforce.com/articleView?id=000213290&type=1&language=en_US) so Note create/update/owner fields can be preserved on the new enhanced notes 109 | 2. Enable setting [New Notes](https://help.salesforce.com/articleView?id=notes_admin_setup.htm) so ContentNote object exists and the new note-taking tool is available 110 | 3. Add "Notes" related list to your page layouts (e.g. Accounts, Contacts, Tasks, Events, etc.) 111 | 4. Deploy the package using one of the installation links above 112 | 5. Assign yourself the permission set **Convert Notes to Enhanced Notes** then switch to the app by the same name 113 | 6. On the **Convert Notes to Enhanced Notes** tab page, click on **Setup Conversion Settings** to configure sharing and conversion behavior 114 | 7. Perform a **test** conversion 115 | 8. Consider **automating** conversion 116 | 117 | ![screen shot](images/pages-main-menu.png) 118 | 119 | ![screen shot](images/pages-conversion-settings.png) 120 | 121 | 122 | FAQ 123 | === 124 | 125 | Salesforce System Errors 126 | ------------------------------------------------------------------------------------------- 127 | 128 | > Salesforce System Error: 208410828-86802 (1615802660) 129 | 130 | > Salesforce System Error: 623811003-63707 (123133497) 131 | 132 | According to [Salesforce documentation](https://help.salesforce.com/articleView?id=000230867&type=1&language=en_US), 133 | > ContentNote currently provides a generic error like "Note could not be saved." whenever an issue is encountered while parsing the Note file itself. This is often an indication of an issue with the contents of the text body (Content) file, such as special character(s) that have not been properly escaped. 134 | 135 | Any conversion errors are reported in the **Convert Notes to Enhanced Notes Logs** object. 136 | If the details reported there are not sufficient for you to resolve the conversion issue then 137 | if you trust me share with me the note trying to be converted and I'll see if I can pinpoint if it's due to special characters or encoding. 138 | 139 | You may need to manually export your `Note` records and replace any special characters then update them back into Salesforce prior attempting to convert them. Please see comments made by **Rachel Park** on [this thread](https://success.salesforce.com/0D53A00003AH4SV) in Success Community on 8/23/2017: 140 | 141 | > "Using Data Loader, I actually exported all of the original Notes and did a find/replace on the special characters in Excel and then updated the records in Salesforce. When I ran the conversion process again afterwards, everything completed fine!" 142 | 143 | 144 | Regex too complicated 145 | --------------------- 146 | You may encounter this error if your original note is very large or contains a lot of HTML formatting. 147 | 148 | If you trust me share with me the note trying to be converted and I'll see if I can pinpoint the cause and provide a fix. 149 | 150 | 151 | Max Documents or Versions Published Governor Limit 152 | -------------------------------------------------- 153 | When converting classic Notes & Attachments the new data is stored in the `ContentVersion` object. 154 | There is a [limit to how many of these records can be created in a 24 hour period](https://help.salesforce.com/articleView?id=limits_general.htm&language=en_US&type=0). 155 | With [Summer '17](https://releasenotes.docs.salesforce.com/en-us/summer17/release-notes/rn_files_limits.htm) release the limit is increased from 36,000 to 200,000! 156 | If you have a lot of Notes & Attachments to convert plan around this limit and split the work across multiple days. 157 | 158 | 159 | Field is not writeable: ContentVersion.CreatedById 160 | -------------------------------------------------- 161 | When you deploy the package you might get error that files are invalid and need recompilation and one of the specific messages 162 | might say "Field is not writeable: ContentVersion.CreatedById". The conversion tool tries to copy the notes's original 163 | created and last modified date/user to the converted enhanced note. To do so then the "Create Audit Fields" feature must be enabled. 164 | Please see [this help article](https://help.salesforce.com/articleView?id=000213290&type=1&language=en_US) for instructions enable this feature. 165 | 166 | ![screen shot](images/setup-enable-create-audit-fields2.png) 167 | 168 | ![screen shot](images/setup-enable-create-audit-fields1.png) 169 | 170 | 171 | Visibility InternalUsers is not permitted for this linked record. 172 | ----------------------------------------------------------------- 173 | When the conversion tool shares the enhanced note to the note's owner and parent record the 174 | **ContentDocumentLink.Visibility** field controls which community of users, internal or external, 175 | may gain access to the enhanced note if they have access to the related record. 176 | 177 | When communities are **enabled** then both picklist values `AllUsers` and `InternalUsers` are acceptable. 178 | When communities are **disabled** then only the picklist value `AllUsers` is acceptable. 179 | 180 | This error usually means communities are **disabled** in your org and you're trying to set the 181 | visibility of the converted enhanced notes to `InternalUsers`. 182 | 183 | To fix then either (a) enable communities or (b) change the visibility option to `AllUsers`. 184 | 185 | 186 | INSUFFICIENT_ACCESS_OR_READONLY, Invalid sharing type I: [ShareType] 187 | -------------------------------------------------------------------- 188 | This error means the object the new file is trying to be shared to does not support the conversion setting **Users inherit view or edit access to the file based on their view or edit access to the attachment's parent record** and instead you must try **Users can only view the file but cannot edit it, even if the user can edit the attachment's parent record**. 189 | 190 | This is known to occur with `Solution` object and likely other objects. 191 | 192 | 193 | FIELD_INTEGRITY_EXCEPTION, Owner ID: id value of incorrect type: 035xxxxxxxxxxxxxxx: [OwnerId] 194 | ---------------------------------------------------------------------------------------------- 195 | Prior to Spring '12, Salesfore customers could have [Self-Service Portals](https://help.salesforce.com/articleView?id=customize_selfserviceenable.htm), which pre-date the modern Communities we have today. 196 | This error means the Note is owned by a Self-Service User and ContentVersions cannot be owned by them. 197 | You may want to consider changing ownership of those notes to actual user records whose IDs start with **005** prefix. 198 | 199 | 200 | How are private notes converted? 201 | -------------------------------- 202 | Starting with Release 1.3 of the app, private notes are converted to enhanced notes and related to the parent record as you would expect 203 | and the enhanced note's privacy is preserved by using [Winter '18 feature **File Privacy on Records"](https://releasenotes.docs.salesforce.com/en-us/winter18/release-notes/rn_files_on_records.htm). 204 | 205 | Prior to Release 1.3, private notes were handled differently: 206 | > Classic Notes & Attachments have an [IsPrivate](https://help.salesforce.com/apex/HTViewHelpDoc?id=notes_fields.htm) checkbox field that when selected 207 | makes the record only visible to the owner and administrators, even through the 208 | Note or Attachment is related to the parent entity (e.g. Account or Contact). 209 | However, ContentNote object follows a different approach. Rather than an 210 | explicit 'IsPrivate' checkbox it uses a robust sharing model, one of the reasons 211 | to convert to the new enhanced notes to begin with! In this sharing model, to 212 | make a record private then it simpy isn't shared with any other users or records. 213 | The caveat then is that these unshared (private) enhanced notes do not show up 214 | contextually on any Salesforce record. By sharing the new enhanced note with the 215 | original parent record then any user who has visibility to that parent record now 216 | has access to this previously private note. 217 | > 218 | > Therefore, when converting you have the option to: 219 | > 220 | > (a) ignore private notes and not convert them 221 | > 222 | > (b) convert and share them with the parent entity 223 | > 224 | > (c) convert them but don't share them with the parent entity, they will reside in the note owner's private library 225 | 226 | 227 | Inactive Owners 228 | --------------- 229 | ContentNote records cannot be created and be owned by an inactive user. 230 | The enhanced notes must be owned by an active user to be created otherwise get error `INACTIVE_OWNER_OR_USER, owner or user is inactive.`. 231 | Prior to running conversion you should either (a) update all old notes with inactive owners to be owned by an active user, or (b) convert them manually first. 232 | 233 | 234 | If I run the conversion multiple times, do duplicate enhanced notes get created for the same notes? 235 | --------------------------------------------------------------------------------------------------- 236 | No, no duplicate enhanced notes should be created once a note has been converted once. 237 | When notes are converted into enhanced notes we store the `Note.ID` in the `ContentVersion.Original_Record_ID__c` field for tracking purposes. 238 | The conversion logic first checks if there exist any enhanced notes that have been stamped with the note id, if yes then we skip converting that note again. 239 | 240 | Of course, if you choose the conversion option to delete the notes upon conversion then no such note would exist the second time around. 241 | But if you choose to keep the notes post conversion they will not be converted again if you run conversion process multiple times. 242 | 243 | 244 | Disclaimer 245 | ========== 246 | 247 | This is not an official conversion tool by salesforce.com to migrate Notes to Enhanced Notes. 248 | This is a personal project by [Doug Ayers](https://douglascayers.com) to assist customers in migrating to and adopting Enhanced Notes. 249 | Although this tool has been successfully tested with several customers since 2015 that have 250 | between dozens to tens of thousands of notes, please do your own due diligence 251 | and testing in a sandbox before ever attempting this in production. 252 | 253 | Always make a backup of your data before attempting any data conversion operations. 254 | 255 | You may read the project license [here](https://github.com/DouglasCAyers/sfdc-convert-notes-to-chatter-notes/blob/master/LICENSE). 256 | 257 | 258 | Special Thanks 259 | ============== 260 | 261 | * [Arnab Bose](https://www.linkedin.com/in/abosesf/) ([@ArBose](https://twitter.com/ArBose)), Salesforce Product Manager 262 | * [Haris Ikram](https://www.linkedin.com/in/harisikram/) ([@HarisIkramH](https://twitter.com/HarisIkramH)), Salesforce Product Manager 263 | * [Henry Liu](https://www.linkedin.com/in/yenjuilhenryliu/), Salesforce Product Manager 264 | * [David Mendelson](https://www.linkedin.com/in/davidmendelson/), Salesforce Product Manager 265 | * [Neil Hayek](https://success.salesforce.com/_ui/core/userprofile/UserProfilePage?u=00530000003SpRmAAK), Salesforce Chatter Expert 266 | * [Arthur Louie](http://salesforce.stackexchange.com/users/1099/alouie?tab=topactivity), Salesforce Chatter Expert 267 | * [Rick MacGuigan](https://www.linkedin.com/in/rick-macguigan-4406592b/), a very helpful early adopter and tester! 268 | * [David Reed](https://github.com/davidmreed/DMRNoteAttachmentImporter), for insight how to [escape](https://help.salesforce.com/articleView?id=000230867&type=1&language=en_US) HTML tags in original note content 269 | * And to everyone who has provided feedback on this project to make it what it is today, thank you! 270 | -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesBatchableTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesToContentNotesBatchableTest { 6 | 7 | @isTest 8 | static void test_real_time_trigger() { 9 | 10 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 11 | settings.Convert_in_Near_Real_Time__c = true; 12 | settings.Delete_Note_Once_Converted__c = true; 13 | 14 | upsert settings; 15 | 16 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 17 | 18 | User user1, user2; 19 | 20 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 21 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 22 | 23 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Alpha', 'User 1', 'user_1@example.com' ); 24 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Beta', 'User 2', 'user_2@example.com' ); 25 | 26 | insert new List{ user1, user2 }; 27 | 28 | } 29 | 30 | Account acct1 = new Account( 31 | name = 'Test Account', 32 | ownerId = user1.id 33 | ); 34 | 35 | insert acct1; 36 | 37 | Note note1 = new Note( 38 | title = 'Hello World.txt', 39 | body = 'Goodnight Moon', 40 | parentId = acct1.id, 41 | ownerId = user1.id 42 | ); 43 | 44 | Test.startTest(); 45 | 46 | System.runAs( user1 ) { 47 | 48 | insert note1; 49 | 50 | } 51 | 52 | Test.stopTest(); 53 | 54 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 55 | System.debug( log ); 56 | } 57 | 58 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id ] ); 59 | System.assertEquals( 0, [ SELECT count() FROM Note WHERE id = :note1.id ] ); 60 | 61 | ContentDocumentLink cdl_note1 = [ 62 | SELECT 63 | id, 64 | contentDocumentId, 65 | contentDocument.latestPublishedVersion.original_record_id__c, 66 | contentDocument.latestPublishedVersion.original_record_parent_id__c, 67 | contentDocument.latestPublishedVersion.original_record_owner_id__c 68 | FROM 69 | ContentDocumentLink 70 | WHERE 71 | linkedEntityId = :acct1.id 72 | AND 73 | contentDocument.latestPublishedVersion.original_record_id__c = :note1.id 74 | ]; 75 | 76 | System.assertEquals( note1.id, cdl_note1.contentDocument.latestPublishedVersion.original_record_id__c ); 77 | System.assertEquals( note1.parentId, cdl_note1.contentDocument.latestPublishedVersion.original_record_parent_id__c ); 78 | System.assertEquals( note1.ownerId, cdl_note1.contentDocument.latestPublishedVersion.original_record_owner_id__c ); 79 | 80 | } 81 | 82 | @isTest 83 | static void test_scope_conversion() { 84 | 85 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 86 | settings.Convert_in_Near_Real_Time__c = false; 87 | settings.Delete_Note_Once_Converted__c = true; 88 | 89 | upsert settings; 90 | 91 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 92 | 93 | User user1, user2; 94 | 95 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 96 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 97 | 98 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Alpha', 'User 1', 'user_1@example.com' ); 99 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Beta', 'User 2', 'user_2@example.com' ); 100 | 101 | insert new List{ user1, user2 }; 102 | 103 | } 104 | 105 | Account acct1 = new Account( 106 | ownerId = user1.id, 107 | name = 'Test Account 1' 108 | ); 109 | 110 | insert acct1; 111 | 112 | Account acct2 = new Account( 113 | ownerId = user2.id, 114 | name = 'Test Account 2' 115 | ); 116 | 117 | insert acct2; 118 | 119 | Note note1 = new Note( 120 | title = 'Hello World 1.txt', 121 | body = 'Goodnight Moon', 122 | parentId = acct1.id, 123 | ownerId = user1.id 124 | ); 125 | 126 | insert note1; 127 | 128 | Note note2 = new Note( 129 | title = 'Hello World 2.txt', 130 | body = 'Goodnight Moon 2', 131 | parentId = acct2.id, 132 | ownerId = user2.id 133 | ); 134 | 135 | insert note2; 136 | 137 | Test.startTest(); 138 | 139 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 140 | options.parentIds = new Set{ acct1.id }; 141 | 142 | ConvertNotesToContentNotesBatchable batchable = new ConvertNotesToContentNotesBatchable( options ); 143 | 144 | Database.executeBatch( batchable, 100 ); 145 | 146 | Test.stopTest(); 147 | 148 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 149 | System.debug( log ); 150 | } 151 | 152 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct1.id ] ); 153 | System.assertEquals( 0, [ SELECT count() FROM Note WHERE id = :note1.id ] ); 154 | 155 | System.assertEquals( 0, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct2.id ] ); 156 | System.assertEquals( 1, [ SELECT count() FROM Note WHERE id = :note2.id ] ); 157 | 158 | ContentDocumentLink cdl_note1 = [ 159 | SELECT 160 | id, 161 | contentDocumentId, 162 | contentDocument.latestPublishedVersion.original_record_id__c, 163 | contentDocument.latestPublishedVersion.original_record_parent_id__c, 164 | contentDocument.latestPublishedVersion.original_record_owner_id__c 165 | FROM 166 | ContentDocumentLink 167 | WHERE 168 | linkedEntityId = :acct1.id 169 | AND 170 | contentDocument.latestPublishedVersion.original_record_id__c = :note1.id 171 | ]; 172 | 173 | System.assertEquals( note1.id, cdl_note1.contentDocument.latestPublishedVersion.original_record_id__c ); 174 | System.assertEquals( note1.parentId, cdl_note1.contentDocument.latestPublishedVersion.original_record_parent_id__c ); 175 | System.assertEquals( note1.ownerId, cdl_note1.contentDocument.latestPublishedVersion.original_record_owner_id__c ); 176 | 177 | } 178 | 179 | @isTest 180 | static void test_preserve_original_owner() { 181 | 182 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 183 | settings.Convert_in_Near_Real_Time__c = false; 184 | settings.Delete_Note_Once_Converted__c = true; 185 | 186 | upsert settings; 187 | 188 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 189 | 190 | User user1, user2; 191 | 192 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 193 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 194 | 195 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Alpha', 'User 1', 'user_1@example.com' ); 196 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Beta', 'User 2', 'user_2@example.com' ); 197 | 198 | insert new List{ user1, user2 }; 199 | 200 | } 201 | 202 | Account acct = new Account( 203 | ownerId = user1.id, 204 | name = 'Test Account' 205 | ); 206 | 207 | insert acct; 208 | 209 | Note note1 = new Note( 210 | title = 'Hello World 1.txt', 211 | body = 'Goodnight Moon', 212 | parentId = acct.id, 213 | ownerId = user1.id 214 | ); 215 | 216 | insert note1; 217 | 218 | Note note2 = new Note( 219 | title = 'Hello World 2.txt', 220 | body = 'Goodnight Moon 2', 221 | parentId = acct.id, 222 | ownerId = user2.id 223 | ); 224 | 225 | insert note2; 226 | 227 | Test.startTest(); 228 | 229 | ConvertNotesToContentNotesBatchable batchable = new ConvertNotesToContentNotesBatchable(); 230 | 231 | Database.executeBatch( batchable, 100 ); 232 | 233 | Test.stopTest(); 234 | 235 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 236 | System.debug( log ); 237 | } 238 | 239 | ContentDocumentLink cdl_note1 = [ 240 | SELECT 241 | id, 242 | contentDocument.fileType, 243 | contentDocumentId, 244 | contentDocument.latestPublishedVersion.original_record_id__c, 245 | contentDocument.latestPublishedVersion.original_record_parent_id__c, 246 | contentDocument.latestPublishedVersion.original_record_owner_id__c 247 | FROM 248 | ContentDocumentLink 249 | WHERE 250 | linkedEntityId = :acct.id 251 | AND 252 | contentDocument.latestPublishedVersion.original_record_id__c = :note1.id 253 | ]; 254 | 255 | System.assertEquals( 'SNOTE', cdl_note1.contentDocument.fileType ); 256 | System.assertEquals( note1.id, cdl_note1.contentDocument.latestPublishedVersion.original_record_id__c ); 257 | System.assertEquals( note1.parentId, cdl_note1.contentDocument.latestPublishedVersion.original_record_parent_id__c ); 258 | System.assertEquals( note1.ownerId, cdl_note1.contentDocument.latestPublishedVersion.original_record_owner_id__c ); 259 | 260 | ContentDocumentLink cdl_note2 = [ 261 | SELECT 262 | id, 263 | contentDocumentId, 264 | contentDocument.fileType, 265 | contentDocument.latestPublishedVersion.original_record_id__c, 266 | contentDocument.latestPublishedVersion.original_record_parent_id__c, 267 | contentDocument.latestPublishedVersion.original_record_owner_id__c 268 | FROM 269 | ContentDocumentLink 270 | WHERE 271 | linkedEntityId = :acct.id 272 | AND 273 | contentDocument.latestPublishedVersion.original_record_id__c = :note2.id 274 | ]; 275 | 276 | System.assertEquals( 'SNOTE', cdl_note2.contentDocument.fileType ); 277 | System.assertEquals( note2.id, cdl_note2.contentDocument.latestPublishedVersion.original_record_id__c ); 278 | System.assertEquals( note2.parentId, cdl_note2.contentDocument.latestPublishedVersion.original_record_parent_id__c ); 279 | System.assertEquals( note2.ownerId, cdl_note2.contentDocument.latestPublishedVersion.original_record_owner_id__c ); 280 | 281 | } 282 | 283 | @isTest 284 | static void test_preserve_original_inactive_owner() { 285 | 286 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 287 | settings.Convert_in_Near_Real_Time__c = false; 288 | settings.Delete_Note_Once_Converted__c = true; 289 | 290 | upsert settings; 291 | 292 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 293 | 294 | User user1, user2; 295 | 296 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 297 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 298 | 299 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Alpha', 'User 1', 'user_1@example.com' ); 300 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Beta', 'User 2', 'user_2@example.com' ); 301 | 302 | insert new List{ user1, user2 }; 303 | 304 | } 305 | 306 | Account acct = new Account( 307 | ownerId = user1.id, 308 | name = 'Test Account' 309 | ); 310 | 311 | insert acct; 312 | 313 | System.runAs( user1 ) { 314 | 315 | Note note1 = new Note( 316 | title = 'Hello World 1.txt', 317 | body = 'Goodnight Moon', 318 | parentId = acct.id, 319 | ownerId = user1.id 320 | ); 321 | 322 | insert note1; 323 | 324 | } 325 | 326 | System.runAs( user2 ) { 327 | 328 | Note note2 = new Note( 329 | title = 'Hello World 2.txt', 330 | body = 'Goodnight Moon 2', 331 | parentId = acct.id, 332 | ownerId = user2.id 333 | ); 334 | 335 | insert note2; 336 | 337 | } 338 | 339 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 340 | 341 | user2.isActive = false; 342 | update user2; 343 | 344 | } 345 | 346 | Test.startTest(); 347 | 348 | ConvertNotesToContentNotesBatchable batchable = new ConvertNotesToContentNotesBatchable(); 349 | 350 | Database.executeBatch( batchable, 100 ); 351 | 352 | Test.stopTest(); 353 | 354 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 355 | System.debug( log ); 356 | } 357 | 358 | System.assertEquals( 2, [ SELECT count() FROM Convert_Notes_to_ContentNotes_Log__c ] ); 359 | System.assertEquals( 2, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct.id ] ); 360 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct.id AND contentDocument.ownerId = :user1.id ] ); 361 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE linkedEntityId = :acct.id AND contentDocument.ownerId = :user2.id ] ); 362 | System.assertEquals( 0, [ SELECT count() FROM Note ] ); 363 | 364 | } 365 | 366 | @isTest 367 | static void test_conversion_error() { 368 | 369 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 370 | settings.Convert_in_Near_Real_Time__c = false; 371 | settings.Delete_Note_Once_Converted__c = true; 372 | 373 | upsert settings; 374 | 375 | Account acct = new Account( 376 | name = 'Test Account' 377 | ); 378 | 379 | insert acct; 380 | 381 | Note note = new Note( 382 | title = 'Hello World 1', 383 | body = 'Hello World 1', 384 | parentId = acct.id 385 | ); 386 | 387 | insert note; 388 | 389 | Test.startTest(); 390 | 391 | ConvertNotesToContentNotesService.ConversionResult mockResult = new ConvertNotesToContentNotesService.ConversionResult(); 392 | mockResult.status = ConvertNotesToContentNotesService.ConversionResultStatus.ERROR; 393 | mockResult.message = 'Mock Error Result'; 394 | 395 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 396 | 397 | ConvertNotesToContentNotesBatchable batchable = new ConvertNotesToContentNotesBatchable( options ); 398 | 399 | batchable.mockResults = new List{ mockResult }; 400 | 401 | Database.executeBatch( batchable, 100 ); 402 | 403 | Test.stopTest(); 404 | 405 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 406 | System.debug( log ); 407 | } 408 | 409 | System.assertEquals( 1, [ SELECT count() FROM Convert_Notes_to_ContentNotes_Log__c WHERE Status__c = 'ERROR' ] ); 410 | 411 | } 412 | 413 | @isTest 414 | static void test_conversion_exception() { 415 | 416 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 417 | settings.Convert_in_Near_Real_Time__c = false; 418 | settings.Delete_Note_Once_Converted__c = true; 419 | 420 | upsert settings; 421 | 422 | Account acct = new Account( 423 | name = 'Test Account' 424 | ); 425 | 426 | insert acct; 427 | 428 | Note note = new Note( 429 | title = 'Hello World 1', 430 | body = 'Hello World 1', 431 | parentId = acct.id 432 | ); 433 | 434 | insert note; 435 | 436 | Test.startTest(); 437 | 438 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 439 | 440 | ConvertNotesToContentNotesBatchable batchable = new ConvertNotesToContentNotesBatchable( options ); 441 | 442 | batchable.mockException = new System.NullPointerException(); 443 | 444 | Database.executeBatch( batchable, 100 ); 445 | 446 | Test.stopTest(); 447 | 448 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 449 | System.debug( log ); 450 | } 451 | 452 | System.assertEquals( 1, [ SELECT count() FROM Convert_Notes_to_ContentNotes_Log__c WHERE Status__c = 'ERROR' ] ); 453 | 454 | } 455 | 456 | @isTest 457 | static void test_max_records_to_convert() { 458 | 459 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 460 | settings.Convert_in_Near_Real_Time__c = false; 461 | settings.Delete_Note_Once_Converted__c = true; 462 | 463 | upsert settings; 464 | 465 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 466 | 467 | User user1, user2; 468 | 469 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 470 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 471 | 472 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Alpha', 'User 1', 'user_1@example.com' ); 473 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, 'Beta', 'User 2', 'user_2@example.com' ); 474 | 475 | insert new List{ user1, user2 }; 476 | 477 | } 478 | 479 | Account acct = new Account( 480 | ownerId = user1.id, 481 | name = 'Test Account' 482 | ); 483 | 484 | insert acct; 485 | 486 | Note note1 = new Note( 487 | title = 'Hello World 1', 488 | body = 'Hello World 1', 489 | parentId = acct.id, 490 | ownerid = user1.id 491 | ); 492 | 493 | insert note1; 494 | 495 | Note note2 = new Note( 496 | title = 'Hello World 2', 497 | body = 'Hello World 2', 498 | parentId = acct.id, 499 | ownerId = user2.id 500 | ); 501 | 502 | insert note2; 503 | 504 | Test.startTest(); 505 | 506 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions(); 507 | options.maxRecordsToConvert = 1; 508 | 509 | ConvertNotesToContentNotesBatchable batchable = new ConvertNotesToContentNotesBatchable( options ); 510 | batchable.conversionCount = options.maxRecordsToConvert; 511 | 512 | ID jobId = Database.executeBatch( batchable, 100 ); 513 | 514 | Test.stopTest(); 515 | 516 | for ( Convert_Notes_to_ContentNotes_Log__c log : [ SELECT Status__c, Old_Note_ID__c, New_Note_ID__c, Summary__c, Detail__c FROM Convert_Notes_to_ContentNotes_Log__c ] ) { 517 | System.debug( log ); 518 | } 519 | 520 | System.assertEquals( 1, [ SELECT count() FROM AsyncApexJob WHERE id = :jobId AND Status = 'Aborted' ], 'should have aborted job for reaching limit' ); 521 | System.assertEquals( 2, [ SELECT count() FROM Note ], 'should not have deleted notes' ); 522 | System.assertEquals( 0, [ SELECT count() FROM ContentDocumentLink WHERE LinkedEntityId = :acct.id ], 'should not have converted notes' ); 523 | 524 | } 525 | 526 | } -------------------------------------------------------------------------------- /src/classes/ConvertNotesToContentNotesServiceTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Developed by Doug Ayers (douglascayers.com) 3 | */ 4 | @isTest 5 | private class ConvertNotesToContentNotesServiceTest { 6 | 7 | @isTest 8 | static void test_special_characters() { 9 | 10 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 11 | settings.Convert_in_Near_Real_Time__c = false; 12 | settings.Delete_Note_Once_Converted__c = true; 13 | 14 | upsert settings; 15 | 16 | UserRole role = [ SELECT id FROM UserRole WHERE parentRoleId = null AND portalType = 'None' LIMIT 1 ]; 17 | 18 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 19 | 20 | User user1, user2; 21 | 22 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 23 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 24 | 25 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Alpha', 'User 1', 'user_1@example.com' ); 26 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Beta', 'User 2', 'user_2@example.com' ); 27 | 28 | insert new List{ user1, user2 }; 29 | 30 | } 31 | 32 | Account account = new Account( 33 | ownerId = user1.id, 34 | name = 'Test Account' 35 | ); 36 | 37 | insert account; 38 | 39 | Note emptyNote = new Note( 40 | title = 'Hello World.txt', 41 | body = '', 42 | parentId = account.id, 43 | ownerId = user1.id, 44 | createdById = user1.id 45 | ); 46 | 47 | Note plainNote = new Note( 48 | title = 'Hello World.txt', 49 | body = 'Goodnight Moon', 50 | parentId = account.id, 51 | ownerId = user1.id, 52 | createdById = user1.id 53 | ); 54 | 55 | Note specialTitleNote = new Note( 56 | title = 'Hello ! @ # $ % ^ & * ( ) + = - _ \' \" , . ? / \\ [ ] { } | ` ~ < > ¢ © ®', 57 | body = 'Goodnight Moon', 58 | parentId = account.id, 59 | ownerId = user1.id, 60 | createdById = user1.id 61 | ); 62 | 63 | Note specialBodyNote = new Note( 64 | title = 'Hello World.txt', 65 | body = 'Hello ! @ # $ % ^ & * ( ) + = - _ \' \" , . ? / \\ [ ] { } | ` ~ < > ¢ © ®', 66 | parentId = account.id, 67 | ownerId = user1.id, 68 | createdById = user1.id 69 | ); 70 | 71 | Note specialTitleAndBodyNote = new Note( 72 | title = 'Hello ! @ # $ % ^ & * ( ) + = - _ \' \" , . ? / \\ [ ] { } | ` ~ < > ¢ © ®', 73 | body = 'Hello ! @ # $ % ^ & * ( ) + = - _ \' \" , . ? / \\ [ ] { } | ` ~ < > ¢ © ®', 74 | parentId = account.id, 75 | ownerId = user1.id, 76 | createdById = user1.id 77 | ); 78 | 79 | Note htmlBodyNote = new Note( 80 | title = 'Hello World.html', 81 | body = 'bold underline italic
  • list item
  1. list item
link

  © ™ text ', 82 | parentId = account.id, 83 | ownerId = user1.id, 84 | createdById = user1.id 85 | ); 86 | 87 | Note githubIssue8 = new Note( 88 | title = 'Meeting Agenda 2016-06-16 agenda.pdf', 89 | body = 'Meeting Agenda 2016-06-16 agenda.pdf', 90 | parentId = account.id, 91 | ownerId = user1.id, 92 | createdById = user1.id 93 | ); 94 | 95 | Note[] notes = new Note[] { emptyNote, plainNote, specialTitleNote, specialBodyNote, specialTitleAndBodyNote, htmlBodyNote, githubIssue8 }; 96 | 97 | // ensure user1 owns the records 98 | System.runAs( user1 ) { 99 | insert notes; 100 | } 101 | 102 | notes = [ 103 | SELECT 104 | id, parentId, ownerId, title, body, isPrivate, 105 | createdById, createdDate, lastModifiedById, lastModifiedDate 106 | FROM 107 | Note 108 | WHERE 109 | id = :notes 110 | ]; 111 | 112 | Test.startTest(); 113 | 114 | List results = null; 115 | 116 | System.runAs( user1 ) { 117 | 118 | ConvertNotesToContentNotesService service = new ConvertNotesToContentNotesService(); 119 | 120 | results = service.convert( notes ); 121 | 122 | } 123 | 124 | Test.stopTest(); 125 | 126 | System.assertNotEquals( null, results ); 127 | 128 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 129 | System.debug( result ); 130 | System.assertEquals( ConvertNotesToContentNotesService.ConversionResultStatus.CONVERTED, result.status, result.message ); 131 | } 132 | 133 | } 134 | 135 | @isTest 136 | static void test_convert_real_time() { 137 | 138 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 139 | settings.Convert_in_Near_Real_Time__c = true; 140 | settings.Delete_Note_Once_Converted__c = false; 141 | 142 | upsert settings; 143 | 144 | UserRole role = [ SELECT id FROM UserRole WHERE parentRoleId = null AND portalType = 'None' LIMIT 1 ]; 145 | 146 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 147 | 148 | User user1, user2; 149 | 150 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 151 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 152 | 153 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Alpha', 'User 1', 'user_1@example.com' ); 154 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Beta', 'User 2', 'user_2@example.com' ); 155 | 156 | insert new List{ user1, user2 }; 157 | 158 | } 159 | 160 | Account account = new Account( 161 | ownerId = user1.id, 162 | name = 'Test Account' 163 | ); 164 | 165 | insert account; 166 | 167 | Note note = new Note( 168 | title = 'Hello World.txt', 169 | body = 'Goodnight Moon', 170 | parentId = account.id, 171 | ownerId = user1.id 172 | ); 173 | 174 | Test.startTest(); 175 | 176 | insert note; 177 | 178 | Test.stopTest(); 179 | 180 | System.runAs( user1 ) { 181 | 182 | ContentVersion cv = [ SELECT id, contentDocumentId, sharingPrivacy FROM ContentVersion WHERE original_record_id__c = :note.id AND isLatest = true ]; 183 | System.assert( cv != null ); 184 | System.assertEquals( 'N', cv.sharingPrivacy ); 185 | 186 | ContentDocumentLink cdl = [ SELECT id, linkedEntityId FROM ContentDocumentLink WHERE contentDocumentId = :cv.contentDocumentId AND linkedEntityId = :account.id ]; 187 | System.assert( cdl != null ); 188 | 189 | ContentNote cn = [ SELECT id, latestPublishedVersionId FROM ContentNote WHERE latestPublishedVersionId = :cv.id ]; 190 | System.assert( cn != null ); 191 | 192 | List notes = new List([ SELECT id FROM Note WHERE id = :note.id ]); 193 | System.assertEquals( 1, notes.size() ); 194 | 195 | UserRecordAccess user2access = [ SELECT recordId, hasReadAccess FROM UserRecordAccess WHERE userId = :user2.id AND recordId = :cv.id ]; 196 | System.assertEquals( true, user2access.hasReadAccess, 'not note owner should see not private note' ); 197 | 198 | } 199 | 200 | } 201 | 202 | @isTest 203 | static void test_no_delete() { 204 | 205 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 206 | settings.Convert_in_Near_Real_Time__c = false; 207 | settings.Delete_Note_Once_Converted__c = false; 208 | 209 | upsert settings; 210 | 211 | UserRole role = [ SELECT id FROM UserRole WHERE parentRoleId = null AND portalType = 'None' LIMIT 1 ]; 212 | 213 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 214 | 215 | User user1, user2; 216 | 217 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 218 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 219 | 220 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Alpha', 'User 1', 'user_1@example.com' ); 221 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Beta', 'User 2', 'user_2@example.com' ); 222 | 223 | insert new List{ user1, user2 }; 224 | 225 | } 226 | 227 | Account account = new Account( 228 | ownerId = user1.id, 229 | name = 'Test Account' 230 | ); 231 | 232 | insert account; 233 | 234 | Note note = new Note( 235 | title = 'Hello World.txt', 236 | body = 'Goodnight Moon', 237 | parentId = account.id, 238 | ownerId = user1.id 239 | ); 240 | 241 | insert note; 242 | 243 | note = [ 244 | SELECT 245 | id, parentId, ownerId, title, body, isPrivate, 246 | createdById, createdDate, lastModifiedById, lastModifiedDate 247 | FROM 248 | Note 249 | WHERE 250 | id = :note.id 251 | ]; 252 | 253 | Test.startTest(); 254 | 255 | ConvertNotesToContentNotesService service = new ConvertNotesToContentNotesService(); 256 | 257 | List results = service.convert( new Note[]{ note } ); 258 | 259 | Test.stopTest(); 260 | 261 | System.assertNotEquals( null, results ); 262 | 263 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 264 | System.debug( result ); 265 | System.assertEquals( ConvertNotesToContentNotesService.ConversionResultStatus.CONVERTED, result.status, result.message ); 266 | } 267 | 268 | System.runAs( user1 ) { 269 | 270 | ContentVersion cv = [ SELECT id, contentDocumentId FROM ContentVersion WHERE original_record_id__c = :note.id AND isLatest = true ]; 271 | System.assert( cv != null ); 272 | 273 | ContentDocumentLink cdl = [ SELECT id, linkedEntityId FROM ContentDocumentLink WHERE contentDocumentId = :cv.contentDocumentId AND linkedEntityId = :account.id ]; 274 | System.assert( cdl != null ); 275 | 276 | ContentNote cn = [ SELECT id, latestPublishedVersionId FROM ContentNote WHERE latestPublishedVersionId = :cv.id ]; 277 | System.assert( cn != null ); 278 | 279 | List notes = new List( [ SELECT id FROM Note WHERE id = :note.id ] ); 280 | System.assertEquals( 1, notes.size() ); 281 | 282 | } 283 | 284 | } 285 | 286 | @isTest 287 | static void test_yes_delete() { 288 | 289 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 290 | settings.Convert_in_Near_Real_Time__c = false; 291 | settings.Delete_Note_Once_Converted__c = true; 292 | 293 | upsert settings; 294 | 295 | UserRole role = [ SELECT id FROM UserRole WHERE parentRoleId = null AND portalType = 'None' LIMIT 1 ]; 296 | 297 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 298 | 299 | User user1, user2; 300 | 301 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 302 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 303 | 304 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Alpha', 'User 1', 'user_1@example.com' ); 305 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Beta', 'User 2', 'user_2@example.com' ); 306 | 307 | insert new List{ user1, user2 }; 308 | 309 | } 310 | 311 | Account account = new Account( 312 | ownerId = user1.id, 313 | name = 'Test Account' 314 | ); 315 | 316 | insert account; 317 | 318 | Note note = new Note( 319 | title = 'Hello World.txt', 320 | body = 'Goodnight Moon', 321 | parentId = account.id, 322 | ownerId = user1.id 323 | ); 324 | 325 | insert note; 326 | 327 | note = [ 328 | SELECT 329 | id, parentId, ownerId, title, body, isPrivate, 330 | createdById, createdDate, lastModifiedById, lastModifiedDate 331 | FROM 332 | Note 333 | WHERE 334 | id = :note.id 335 | ]; 336 | 337 | Test.startTest(); 338 | 339 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions(); 340 | options.deleteNotesUponConversion = true; 341 | 342 | ConvertNotesToContentNotesService service = new ConvertNotesToContentNotesService( options ); 343 | 344 | List results = service.convert( new Note[]{ note } ); 345 | 346 | Test.stopTest(); 347 | 348 | System.assertNotEquals( null, results ); 349 | 350 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 351 | System.debug( result ); 352 | System.assertEquals( ConvertNotesToContentNotesService.ConversionResultStatus.CONVERTED, result.status, result.message ); 353 | } 354 | 355 | System.runAs( user1 ) { 356 | 357 | ContentVersion cv = [ SELECT id, contentDocumentId FROM ContentVersion WHERE original_record_id__c = :note.id AND isLatest = true ]; 358 | System.assert( cv != null ); 359 | 360 | ContentDocumentLink cdl = [ SELECT id, linkedEntityId FROM ContentDocumentLink WHERE contentDocumentId = :cv.contentDocumentId AND linkedEntityId = :account.id ]; 361 | System.assert( cdl != null ); 362 | 363 | ContentNote cn = [ SELECT id, latestPublishedVersionId FROM ContentNote WHERE latestPublishedVersionId = :cv.id ]; 364 | System.assert( cn != null ); 365 | 366 | List notes = new List( [ SELECT id FROM Note WHERE id = :note.id ] ); 367 | System.assertEquals( 0, notes.size() ); 368 | 369 | } 370 | 371 | } 372 | 373 | @isTest 374 | static void test_share_private_notes_with_parent_record() { 375 | 376 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 377 | settings.Convert_in_Near_Real_Time__c = false; 378 | settings.Delete_Note_Once_Converted__c = true; 379 | 380 | upsert settings; 381 | 382 | UserRole role = [ SELECT id FROM UserRole WHERE parentRoleId = null AND portalType = 'None' LIMIT 1 ]; 383 | 384 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 385 | 386 | User user1, user2; 387 | 388 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 389 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 390 | 391 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Alpha', 'User 1', 'user_1@example.com' ); 392 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Beta', 'User 2', 'user_2@example.com' ); 393 | 394 | insert new List{ user1, user2 }; 395 | 396 | } 397 | 398 | Account account = new Account( 399 | ownerId = user1.id, 400 | name = 'Test Account' 401 | ); 402 | 403 | insert account; 404 | 405 | Note note = new Note( 406 | title = 'Hello World.txt', 407 | body = 'Goodnight Moon', 408 | parentId = account.id, 409 | ownerId = user1.id, 410 | isPrivate = true 411 | ); 412 | 413 | insert note; 414 | 415 | note = [ 416 | SELECT 417 | id, parentId, ownerId, title, body, isPrivate, 418 | createdById, createdDate, lastModifiedById, lastModifiedDate 419 | FROM 420 | Note 421 | WHERE 422 | id = :note.id 423 | ]; 424 | 425 | Test.startTest(); 426 | 427 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions(); 428 | 429 | ConvertNotesToContentNotesService service = new ConvertNotesToContentNotesService( options ); 430 | 431 | List results = service.convert( new Note[]{ note } ); 432 | 433 | Test.stopTest(); 434 | 435 | System.runAs( user1 ) { 436 | 437 | System.assertNotEquals( null, results ); 438 | 439 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 440 | System.debug( result ); 441 | System.assertEquals( ConvertNotesToContentNotesService.ConversionResultStatus.CONVERTED, result.status, result.message ); 442 | } 443 | 444 | ContentVersion cv = [ SELECT id, contentDocumentId, sharingPrivacy FROM ContentVersion WHERE original_record_id__c = :note.id AND isLatest = true ]; 445 | System.assert( cv != null ); 446 | System.assertEquals( 'P', cv.sharingPrivacy ); 447 | 448 | ContentDocumentLink cdl = [ SELECT id, linkedEntityId FROM ContentDocumentLink WHERE contentDocumentId = :cv.contentDocumentId AND linkedEntityId = :account.id ]; 449 | System.assert( cdl != null ); 450 | 451 | UserRecordAccess user2access = [ SELECT recordId, hasReadAccess FROM UserRecordAccess WHERE userId = :user2.id AND recordId = :cv.id ]; 452 | System.assertEquals( false, user2access.hasReadAccess, 'not note owner should not see private note' ); 453 | 454 | } 455 | 456 | } 457 | 458 | @isTest 459 | static void test_no_duplicates() { 460 | 461 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 462 | settings.Convert_in_Near_Real_Time__c = false; 463 | settings.Delete_Note_Once_Converted__c = false; 464 | 465 | upsert settings; 466 | 467 | UserRole role = [ SELECT id FROM UserRole WHERE parentRoleId = null AND portalType = 'None' LIMIT 1 ]; 468 | 469 | Profile p = [ SELECT id FROM Profile WHERE name = 'Standard User' ]; 470 | 471 | User user1, user2; 472 | 473 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm 474 | System.runAs( new User( id = UserInfo.getUserId() ) ) { 475 | 476 | user1 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Alpha', 'User 1', 'user_1@example.com' ); 477 | user2 = ConvertNotesToContentNotesTestFactory.newUser( p.id, role.id, 'Beta', 'User 2', 'user_2@example.com' ); 478 | 479 | insert new List{ user1, user2 }; 480 | 481 | } 482 | 483 | Account account = new Account( 484 | ownerId = user1.id, 485 | name = 'Test Account' 486 | ); 487 | 488 | insert account; 489 | 490 | Note note = new Note( 491 | title = 'Hello World.txt', 492 | body = 'Goodnight Moon', 493 | parentId = account.id, 494 | ownerId = user1.id 495 | ); 496 | 497 | System.runAs( user1 ) { 498 | 499 | insert note; 500 | 501 | } 502 | 503 | note = [ 504 | SELECT 505 | id, parentId, ownerId, title, body, isPrivate, 506 | createdById, createdDate, lastModifiedById, lastModifiedDate 507 | FROM 508 | Note 509 | WHERE 510 | id = :note.id 511 | ]; 512 | 513 | Test.startTest(); 514 | 515 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions(); 516 | 517 | ConvertNotesToContentNotesService service = new ConvertNotesToContentNotesService( options ); 518 | 519 | List results = service.convert( new Note[]{ note } ); 520 | 521 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 522 | System.debug( result ); 523 | System.assertEquals( ConvertNotesToContentNotesService.ConversionResultStatus.CONVERTED, result.status, result.message ); 524 | } 525 | 526 | System.assertEquals( 1, results.size() ); 527 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE contentDocument.latestPublishedVersion.original_record_id__c = :note.id AND linkedEntityId = :account.id ] ); 528 | System.assertEquals( 1, [ SELECT count() FROM Note WHERE id = :note.id ] ); 529 | 530 | // convert again, expect no duplicate file created 531 | 532 | results = service.convert( new Note[]{ note } ); 533 | 534 | for ( ConvertNotesToContentNotesService.ConversionResult result : results ) { 535 | System.debug( result ); 536 | System.assertEquals( ConvertNotesToContentNotesService.ConversionResultStatus.SKIPPED, result.status, result.message ); 537 | } 538 | 539 | System.assertEquals( 1, results.size() ); 540 | System.assertEquals( 1, [ SELECT count() FROM ContentDocumentLink WHERE contentDocument.latestPublishedVersion.original_record_id__c = :note.id AND linkedEntityId = :account.id ] ); 541 | System.assertEquals( 1, [ SELECT count() FROM Note WHERE id = :note.id ] ); 542 | 543 | Test.stopTest(); 544 | 545 | } 546 | 547 | @isTest 548 | static void test_init_options_from_settings() { 549 | 550 | Convert_Notes_to_ContentNotes_Settings__c settings = Convert_Notes_to_ContentNotes_Settings__c.getOrgDefaults(); 551 | settings.Convert_in_Near_Real_Time__c = true; 552 | settings.Delete_Note_Once_Converted__c = true; 553 | 554 | upsert settings; 555 | 556 | Test.startTest(); 557 | 558 | ConvertNotesToContentNotesOptions options = new ConvertNotesToContentNotesOptions( settings ); 559 | 560 | Test.stopTest(); 561 | 562 | System.assertEquals( settings.Delete_Note_Once_Converted__c, options.deleteNotesUponConversion ); 563 | 564 | } 565 | 566 | } --------------------------------------------------------------------------------