Index: kernel/class/view.php
===================================================================
--- kernel/class/view.php	(revision 23534)
+++ kernel/class/view.php	(working copy)
@@ -33,6 +33,19 @@
 $validation = array( 'processed' => false,
                      'groups' => array() );
 
+if ( isset( $Params['ScheduledScriptID'] ) )
+{
+    $ScheduledScriptID = $Params['ScheduledScriptID'];
+    if ( is_numeric( $ScheduledScriptID ) )
+    {
+        if ( isset( $Params['HasTimedOut'] ) and $Params['HasTimedOut'] == 1 )
+            $validation['timeout'] = true;
+        else
+            $validation['classonly'] = true;
+        $validation['processed'] = true;
+    }
+}
+
 if ( isset( $Params["ClassID"] ) )
     $ClassID = $Params["ClassID"];
 $ClassVersion = null;
@@ -90,6 +103,9 @@
 $tpl->setVariable( 'datatypes', $datatypes );
 $tpl->setVariable( 'validation', $validation );
 
+if ( $validation['timeout'] or $validation['classonly'] )
+    $tpl->setVariable( 'scheduled_script_id', $ScheduledScriptID );
+
 $Result = array();
 $Result['content'] = $tpl->fetch( 'design:class/view.tpl' );
 $Result['path'] = array( array( 'url' => '/class/grouplist/',
Index: kernel/class/edit.php
===================================================================
--- kernel/class/edit.php	(revision 23534)
+++ kernel/class/edit.php	(working copy)
@@ -27,6 +27,13 @@
 //
 
 
+// Initialize timeout protection
+$startTime = time();
+$executionTime = 0;
+$timeoutLimit = ini_get( 'max_execution_time' );
+$timeoutBy = 0.8; // 1 is too late (timeout)
+$hasTimedOut = false;
+
 $Module = $Params['Module'];
 $ClassID = $Params['ClassID'];
 $GroupID = $Params['GroupID'];
@@ -633,65 +640,156 @@
         }
 
         // Class cleanup, update existing class objects according to new changes
+        // $class->storeDefined() is outside of the transaction, on purpose. It is independent of the update of objects now.
+        $class->storeDefined( $attributes );
+        $classAttributes = $class->fetchAttributes();
+        $classAttributeIDs = array();
+        foreach ( $classAttributes as $classAttribute )
+        {
+            $classAttributeIDs[] = $classAttribute->attribute( 'id' );
+        }
+
         $db = eZDB::instance();
-        $db->begin();
 
         $objects = null;
         $objectCount = eZContentObject::fetchSameClassListCount( $ClassID );
-        if ( $objectCount > 0 )
+        if ( !$http->hasPostVariable( 'StoreClassOnly' ) and $objectCount > 0 )
         {
-            // Delete object attributes which have been removed.
-            foreach ( $oldClassAttributes as $oldClassAttribute )
+            $offset = 0;
+            $limit = 100;
+            $objects = eZContentObject::fetchSameClassList( $ClassID, true, $offset, $limit );
+
+            // Add and/or remove attributes for all versions and translations of all objects of this class
+            while ( count( $objects ) > 0 )
             {
-                $attributeExist = false;
-                $oldClassAttributeID = $oldClassAttribute->attribute( 'id' );
-                foreach ( $newClassAttributes as $newClassAttribute )
+                // Run a transaction per $limit objects
+                $db->begin();
+
+                foreach ( $objects as $object )
                 {
-                    $newClassAttributeID = $newClassAttribute->attribute( 'id' );
-                    if ( $oldClassAttributeID == $newClassAttributeID )
-                        $attributeExist = true;
-                }
-                if ( !$attributeExist )
-                {
-                    $objectAttributes = eZContentObjectAttribute::fetchSameClassAttributeIDList( $oldClassAttributeID );
-                    foreach ( $objectAttributes as $objectAttribute )
+                    // Timeout check
+                    $executionTime = time() - $startTime;
+                    if ( $executionTime > ( $timeoutLimit * $timeoutBy ) )
                     {
-                        $objectAttributeID = $objectAttribute->attribute( 'id' );
-                        $objectAttribute->removeThis( $objectAttributeID );
+                        $hasTimedOut = true;
+                        // Since we break out of both the foreach and the while loops, we have to commit here
+                        $db->commit();
+                        break 2;
                     }
-                }
-            }
-            $class->storeDefined( $attributes );
 
-            // Add object attributes which have been added.
-            foreach ( $attributes as $newClassAttribute )
-            {
-                $attributeExist = false;
-                $newClassAttributeID = $newClassAttribute->attribute( 'id' );
-                foreach ( $oldClassAttributes as $oldClassAttribute )
-                {
-                    $oldClassAttributeID = $oldClassAttribute->attribute( 'id' );
-                    if ( $oldClassAttributeID == $newClassAttributeID )
+                    // Main loop content
+                    $contentObjectID = $object->attribute( 'id' );
+                    $objectVersions = $object->versions();
+                    foreach ( $objectVersions as $objectVersion )
                     {
-                        $attributeExist = true;
-                        break;
+                        $versionID = $objectVersion->attribute( 'version' );
+                        $translations = $objectVersion->translations();
+                        foreach ( $translations as $translation )
+                        {
+                            $translationName = $translation->attribute( 'language_code' );
+
+                            // Class attribute IDs of object attributes (not necessarily the same as those in the class, hence the manual sql)
+                            $objectClassAttributeIDs = array();
+                            $rows = $db->arrayQuery( "SELECT DISTINCT id, contentclassattribute_id, data_type_string
+                                                      FROM ezcontentobject_attribute
+                                                      WHERE contentobject_id = $contentObjectID" );
+                            foreach ( $rows as $row )
+                            {
+                                $objectClassAttributeIDs[ $row['id'] ] = $row['contentclassattribute_id'];
+                            }
+
+                            // Quick array diffs
+                            $attributesToRemove = array_diff( $objectClassAttributeIDs, $classAttributeIDs ); // Present in the object, not in the class
+                            $attributesToAdd = array_diff( $classAttributeIDs, $objectClassAttributeIDs ); // Present in the class, not in the object
+
+                            // Remove old attributes
+                            foreach ( $attributesToRemove as $objectAttributeID => $classAttributeID )
+                            {
+                                $objectAttribute = eZContentObjectAttribute::fetch( $objectAttributeID, $versionID );
+                                if ( !is_object( $objectAttribute ) )
+                                    continue;
+                                $objectAttribute->remove( $objectAttributeID );
+                            }
+
+                            // Add new attributes
+                            foreach ( $attributesToAdd as $classAttributeID )
+                            {
+                                $objectAttribute = eZContentObjectAttribute::create( $classAttributeID, $contentObjectID, $versionID, $translationName );
+                                if ( !is_object( $objectAttribute ) )
+                                    continue;
+                                $objectAttribute->setAttribute( 'language_code', $translationName );
+                                $objectAttribute->initialize();
+                                $objectAttribute->store();
+                                $objectAttribute->postInitialize();
+                            }
+                        }
                     }
                 }
-                if ( !$attributeExist )
-                {
-                    $newClassAttribute->initializeObjectAttributes( $objects );
-                }
+
+                $db->commit();
+
+                $offset += $limit;
+                $objects = eZContentObject::fetchSameClassList( $ClassID, true, $offset, $limit );
             }
+
+            // Set the object name to the first attribute, if not set
+            $classAttributes = $class->fetchAttributes();
+
+            // Fetch the first attribute
+            if ( count( $classAttributes ) > 0 && trim( $class->attribute( 'contentobject_name' ) ) == '' )
+            {
+                $db->begin();
+                $identifier = $classAttributes[0]->attribute( 'identifier' );
+                $identifier = '<' . $identifier . '>';
+                $class->setAttribute( 'contentobject_name', $identifier );
+                $class->store();
+                $db->commit();
+            }
         }
-        else
+
+        $http->removeSessionVariable( 'ClassCanStoreTicket' );
+
+        if ( $hasTimedOut or $http->hasPostVariable( 'StoreClassOnly' ) )
         {
-            $class->storeDefined( $attributes );
+            if ( $hasTimedOut )
+            {
+                eZDebug::writeNotice( "Class storing was about to time out after executing for $executionTime seconds. " .
+                                      'If the scriptmonitor extension is not installed, you need to run addmissingobjectattributes.php.',
+                                      'kernel/class/edit.php' );
+            }
+            else // Allow to bypass storing entirely, and send directly to script
+            {
+                eZDebug::writeNotice( 'Only the class has been stored, the content objects are not updated. ' .
+                                      'If the scriptmonitor extension is not installed, you need to run addmissingobjectattributes.php.',
+                                      'kernel/class/edit.php' );
+            }
+
+            // Detect if script monitor extension exists and is enabled
+            if ( in_array( 'ezscriptmonitor', eZExtension::activeExtensions() ) and class_exists( 'eZScheduledScript' ) )
+            {
+                eZDebug::writeNotice( 'Deferring class storing to a cronjob, using the scriptmonitor extension.', 'kernel/class/edit.php' );
+                // Schedule script
+                $script = eZScheduledScript::create( 'addmissingobjectattributes.php',
+                                                     eZINI::instance( 'ezscriptmonitor.ini' )->variable( 'GeneralSettings', 'PhpCliCommand' ) .
+                                                     ' extension/ezscriptmonitor/bin/' . eZScheduledScript::SCRIPT_NAME_STRING .
+                                                     ' -s ' . eZScheduledScript::SITE_ACCESS_STRING . ' --classid=' . $ClassID );
+                $script->store();
+                return $Module->redirectToView( 'view', array( $ClassID ), array( 'Language' => $EditLanguage,
+                                                                                  'ScheduledScriptID' => $script->attribute( 'id' ),
+                                                                                  'HasTimedOut' => ($hasTimedOut ? 1 : 0) ) );
+            }
+            else // Script monitor extension is not installed
+            {
+                // Suggests running addmissingobjectattributes.php manually.
+                return $Module->redirectToView( 'view', array( $ClassID ), array( 'Language' => $EditLanguage,
+                                                                                  'ScheduledScriptID' => 0,
+                                                                                  'HasTimedOut' => ($hasTimedOut ? 1 : 0) ) );
+            }
         }
-
-        $db->commit();
-
-        $http->removeSessionVariable( 'ClassCanStoreTicket' );
-        return $Module->redirectToView( 'view', array( $ClassID ), array( 'Language' => $EditLanguage ) );
+        else // Normal situation
+        {
+            return $Module->redirectToView( 'view', array( $ClassID ), array( 'Language' => $EditLanguage ) );
+        }
     }
 }
 
@@ -753,6 +851,7 @@
 $tpl->setVariable( 'datatypes', $datatypes );
 $tpl->setVariable( 'datatype', $cur_datatype );
 $tpl->setVariable( 'language_code', $EditLanguage );
+$tpl->setVariable( 'store_class_only', $http->hasPostVariable( 'StoreClassOnly' ) );
 
 $Result = array();
 $Result['content'] = $tpl->fetch( 'design:class/edit.tpl' );
Index: kernel/class/module.php
===================================================================
--- kernel/class/module.php	(revision 23534)
+++ kernel/class/module.php	(working copy)
@@ -40,7 +40,9 @@
     "script" => "view.php",
     "default_navigation_part" => 'ezsetupnavigationpart',
     "params" => array( "ClassID" ),
-    'unordered_params' => array( 'language' => 'Language' ) );
+    'unordered_params' => array( 'language' => 'Language',
+                                 'scriptid' => 'ScheduledScriptID',
+                                 'timeout' => 'HasTimedOut' ) );
 $ViewList["copy"] = array(
     "script" => "copy.php",
     'ui_context' => 'edit',
Index: design/admin/templates/class/edit.tpl
===================================================================
--- design/admin/templates/class/edit.tpl	(revision 23534)
+++ design/admin/templates/class/edit.tpl	(working copy)
@@ -244,8 +244,14 @@
 
 </div>
 
+<div class="block">
+<label>{'Storing changes:'|i18n( 'design/admin/class/edit' )}</label>
+<input type="checkbox" name="StoreClassOnly" value="1" {if $store_class_only}checked="checked"{/if} title="{'Check this if you know that the class has too many objects to be able to update them all without timing out. If you are unsure, it is safe to leave it unchecked.'|i18n( 'design/admin/class/edit' )|wash}" />
+{'Store class only, do not update content objects.'|i18n( 'design/admin/class/edit' )}
 </div>
 
+</div>
+
 {* DESIGN: Content END *}</div></div></div>
 
 
Index: design/admin/templates/class/view.tpl
===================================================================
--- design/admin/templates/class/view.tpl	(revision 23534)
+++ design/admin/templates/class/view.tpl	(working copy)
@@ -11,6 +11,35 @@
 </ul>
 </div>
 {/section}
+{if $validation.timeout}
+    <div class="message-warning">
+        <h2><span class="time">[{currentdate()|l10n( shortdatetime )}]</span> {'Class storing timeout'|i18n( 'design/admin/class/view' )}</h2>
+        {if $scheduled_script_id|gt(0)} {* If the script monitor extension is installed *}
+            <p>
+                {'The storing of the class was halted because it was about to time out. This can occur when the class you edit has a large amount of objects. The class itself is stored, but not all objects have been updated. The process has been scheduled to run in the background, and will be started automatically. Please do not edit the class again until the process has finished. You can monitor the progress of the background process here:'|i18n( 'design/admin/class/view' )}<br />
+                <b><a href={concat('scriptmonitor/view/',$scheduled_script_id)|ezurl}>{'Background process monitor'|i18n( 'design/admin/class/view' )}</a></b>
+            </p>
+        {else}
+            <p>
+                {'The storing of the class was halted because it was about to time out. This can occur when the class you edit has a large amount of objects. The class itself is stored, but not all objects have been updated. This update process must be finished by running the command line script "addmissingobjectattributes.php -s (your admin site access) --classid=%class_id". Please do not edit the class again until the process has finished.'|i18n( 'design/admin/class/view', , hash( '%class_id', $class.id ) )}<br />
+            </p>
+        {/if}
+    </div>
+{elseif $validation.classonly}
+    <div class="message-warning">
+        <h2><span class="time">[{currentdate()|l10n( shortdatetime )}]</span> {'Class storing incomplete'|i18n( 'design/admin/class/view' )}</h2>
+        {if $scheduled_script_id|gt(0)} {* If the script monitor extension is installed *}
+            <p>
+                {'Only the class has been stored, the content objects are not updated. The process has been scheduled to run in the background, and will be started automatically. Please do not edit the class again until the process has finished. You can monitor the progress of the background process here:'|i18n( 'design/admin/class/view' )}<br />
+                <b><a href={concat('scriptmonitor/view/',$scheduled_script_id)|ezurl}>{'Background process monitor'|i18n( 'design/admin/class/view' )}</a></b>
+            </p>
+        {else}
+            <p>
+                {'Only the class has been stored, the content objects are not updated. This update process must be finished by running the command line script "addmissingobjectattributes.php -s (your admin site access) --classid=%class_id". Please do not edit the class again until the process has finished.'|i18n( 'design/admin/class/view', , hash( '%class_id', $class.id ) )}<br />
+            </p>
+        {/if}
+    </div>
+{/if}
 {/section}
 
 
