diff --git a/CHANGED_FILES.md b/CHANGED_FILES.md index c14c600..4adcb01 100644 --- a/CHANGED_FILES.md +++ b/CHANGED_FILES.md @@ -18,8 +18,10 @@ The unused bundled `plugins/help/js/i18n/keynav/ar.js` and `fa.js` files are exc ## New integration files - `model/library/TinyMCEContentSanitizer.php` +- `model/library/TinyMCEMarkdownMigrator.php` - `model/module/TinyMCEContentTable.php` - `route/administrator/tinymce/contents.php` +- `route/administrator/tinymce/migration.php` - `asset/struct/tinymce/pas/content.css` - `asset/struct/tinymce/pas/editor.css` - `asset/struct/tinymce/pas/fonts/B Nazanin.ttf` @@ -46,5 +48,6 @@ The unused bundled `plugins/help/js/i18n/keynav/ar.js` and `fa.js` files are exc - Excel workbook inspection and visual rendering - Font internal-family verification - Local HTTP checks for TinyMCE, CSS, and font assets +- Legacy Markdown dry-run, batched migration, and guarded rollback checks on the local database copy The final authenticated browser test must be repeated after installation on the target website because this delivery intentionally contains no login bypass or credentials. diff --git a/INSTALL-FA.md b/INSTALL-FA.md index 07bd240..c9f7330 100644 --- a/INSTALL-FA.md +++ b/INSTALL-FA.md @@ -8,5 +8,15 @@ 4. کش PHP/OPcache و کش مرورگر را پاک کنید. 5. مسیر `/administrator/content/contents` را باز کنید و ایجاد، ویرایش، جست‌وجو و ذخیرهٔ یک محتوای آزمایشی را بررسی کنید. -برای حذف ماژول، فقط فایل‌های فهرست‌شده در `CHANGED_FILES.md` را از Sequence سایت بردارید. فایل‌های محتوای ذخیره‌شده در پایگاه داده حذف نمی‌شوند. +## تبدیل پست‌های قدیمی + +1. ابتدا از دیتابیس اصلی بک‌آپ کامل بگیرید و آن را روی محیط لوکال بازیابی کنید. +2. با حساب مدیر مسیر `/administrator/tinymce/migration` را باز کنید. +3. تعداد رکوردهای قابل مهاجرت و نمونه‌های Dry Run را بررسی کنید. در این مرحله دیتابیس تغییر نمی‌کند. +4. برای اجرای هر گروه، عبارت `MIGRATE` را وارد کنید. گروه‌ها را تا صفر شدن مقدار «قابل مهاجرت» ادامه دهید. +5. چند پست فارسی و انگلیسی، تصویر، لینک، دکمه، فهرست و جدول را در سایت و داخل ویرایشگر بررسی کنید. +6. اگر نتیجه درست بود، همین روند را فقط پس از تهیه بک‌آپ تازه روی دیتابیس اصلی اجرا کنید. +ماژول پیش از هر تغییر، محتوای اصلی و زمان ویرایش را در جدول پشتیبان اختصاصی ذخیره می‌کند. برای بازگردانی، عبارت `ROLLBACK` را وارد کنید. پستی که پس از مهاجرت ویرایش شده باشد به‌صورت خودکار بازنویسی نمی‌شود. + +برای حذف ماژول، فقط فایل‌های فهرست‌شده در `CHANGED_FILES.md` را از Sequence سایت بردارید. فایل‌های محتوای ذخیره‌شده در پایگاه داده حذف نمی‌شوند. diff --git a/README.md b/README.md index 1a2d0df..302860a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This package installs a self-contained rich content editor on an AseqBase websit - Find and replace inside the editor - Full-content administration search with Persian/Arabic character normalization - Server-side HTML sanitization before content is stored +- Dry-run Markdown migration with batched conversion, database backup, and guarded rollback - English source strings with AseqBase translation support ## Requirements @@ -36,6 +37,14 @@ Import `translations/tinymce-en-fa.csv` through the AseqBase translation importe After installation, open `/administrator/content/contents`. The initializer registers the module route ahead of the generic administrator route only for this URL. +## Migrating legacy Markdown content + +Open `/administrator/tinymce/migration` as an administrator. Review the dry-run totals and samples before applying any changes. The migration converts only Markdown or plain-text records; existing HTML, mixed HTML/Markdown, unsupported custom directives, and unsafe legacy button URLs are skipped. + +Apply the migration in batches. Before each content update, the original content and update time are stored in the module-owned `TinyMCE_ContentBackup` table (with the website database prefix). Rollback restores only records whose migrated HTML has not been edited afterward. + +Always run the migration against a local copy of the production database first. Keep the original SQL backup until the migrated content has been reviewed on the target website. + ## Security and licensing The package bundles TinyMCE Community 8.9.0 under GPL-2.0-or-later. This integration package is licensed under GPL-3.0-only. Submitted content is sanitized on the server; unsafe elements, event handlers, URL schemes, and styles are removed. diff --git a/initialize/tinymce.php b/initialize/tinymce.php index 487c306..728d1f6 100644 --- a/initialize/tinymce.php +++ b/initialize/tinymce.php @@ -14,5 +14,10 @@ 'Access' => \_::$User->AdminAccess, 'Image' => 'edit' ]; + \_::$Front->AdminMenus['Administrator-System']['Items'][] = [ + 'Name' => 'Markdown Content Migration', + 'Path' => '/administrator/tinymce/migration', + 'Access' => \_::$User->AdminAccess, + 'Image' => 'exchange' + ]; } - diff --git a/model/library/TinyMCEMarkdownMigrator.php b/model/library/TinyMCEMarkdownMigrator.php new file mode 100644 index 0000000..f9c0efc --- /dev/null +++ b/model/library/TinyMCEMarkdownMigrator.php @@ -0,0 +1,336 @@ +contentTable = table('Content'); + $this->backupTable = table('TinyMCE_ContentBackup'); + $this->assertTableName($this->contentTable->Name); + $this->assertTableName($this->backupTable->Name); + $this->connection = $this->contentTable->GetDatabase()->Connection(); + } + + public function Analyze(int $sampleLimit = 8): array + { + $records = $this->fetchContentRecords(); + $summary = [ + 'total' => count($records), + 'empty' => 0, + 'plain' => 0, + 'markdown' => 0, + 'html' => 0, + 'mixed' => 0, + 'unsupported' => 0, + 'eligible' => 0, + 'activeBackups' => 0, + 'modifiedAfterMigration' => 0, + 'samples' => [] + ]; + + foreach ($records as $record) { + $analysis = $this->analyzeContent((string)($record['Content'] ?? '')); + $format = $analysis['format']; + $summary[$format]++; + + if ($analysis['eligible']) + $summary['eligible']++; + + if (count($summary['samples']) < max(0, $sampleLimit) + && ($analysis['eligible'] || in_array($format, ['mixed', 'unsupported'], true))) { + $preview = $analysis['eligible'] ? $this->convertAnalyzedContent((string)$record['Content'], $analysis) : null; + $summary['samples'][] = [ + 'Id' => (int)$record['Id'], + 'Title' => (string)($record['Title'] ?? ''), + 'Format' => $format, + 'Issue' => $analysis['issue'], + 'Original' => (string)$record['Content'], + 'Converted' => $preview['html'] ?? null + ]; + } + } + + if ($this->backupTableExists()) { + $query = "SELECT B.MigratedContentHash, C.Content + FROM `{$this->backupTable->Name}` AS B + INNER JOIN `{$this->contentTable->Name}` AS C ON C.Id=B.ContentId + WHERE B.RestoredTime IS NULL"; + foreach ($this->connection->query($query)->fetchAll(\PDO::FETCH_ASSOC) as $backup) { + $summary['activeBackups']++; + if (!hash_equals((string)$backup['MigratedContentHash'], hash('sha256', (string)$backup['Content']))) + $summary['modifiedAfterMigration']++; + } + } + + return $summary; + } + + public function Apply(int $batchSize = self::DEFAULT_BATCH_SIZE): array + { + $batchSize = $this->normalizeBatchSize($batchSize); + $prepared = []; + $skipped = ['mixed' => 0, 'html' => 0, 'unsupported' => 0, 'empty' => 0]; + + foreach ($this->fetchContentRecords() as $record) { + if (count($prepared) >= $batchSize) + break; + + $content = (string)($record['Content'] ?? ''); + $analysis = $this->analyzeContent($content); + if (!$analysis['eligible']) { + if (isset($skipped[$analysis['format']])) + $skipped[$analysis['format']]++; + continue; + } + + $converted = $this->convertAnalyzedContent($content, $analysis); + if ($converted['html'] === $content) + continue; + + $prepared[] = [ + 'Id' => (int)$record['Id'], + 'Title' => (string)($record['Title'] ?? ''), + 'OriginalContent' => $content, + 'OriginalUpdateTime' => $record['UpdateTime'] ?? null, + 'MigratedContent' => $converted['html'], + 'MigratedContentHash' => hash('sha256', $converted['html']) + ]; + } + + if (!$prepared) + return ['migrated' => 0, 'skipped' => $skipped]; + + $this->ensureBackupTable(); + $backup = $this->connection->prepare( + "INSERT INTO `{$this->backupTable->Name}` + (ContentId, Title, OriginalContent, OriginalUpdateTime, MigratedContentHash, CreatedTime, RestoredTime) + VALUES (:ContentId, :Title, :OriginalContent, :OriginalUpdateTime, :MigratedContentHash, CURRENT_TIMESTAMP, NULL) + ON DUPLICATE KEY UPDATE + Title=VALUES(Title), OriginalContent=VALUES(OriginalContent), + OriginalUpdateTime=VALUES(OriginalUpdateTime), MigratedContentHash=VALUES(MigratedContentHash), + CreatedTime=CURRENT_TIMESTAMP, RestoredTime=NULL" + ); + $update = $this->connection->prepare( + "UPDATE `{$this->contentTable->Name}` + SET Content=:MigratedContent, UpdateTime=:OriginalUpdateTime + WHERE Id=:Id AND Content=:OriginalContent" + ); + + try { + $this->connection->beginTransaction(); + foreach ($prepared as $record) { + $backup->execute([ + ':ContentId' => $record['Id'], + ':Title' => $record['Title'], + ':OriginalContent' => $record['OriginalContent'], + ':OriginalUpdateTime' => $record['OriginalUpdateTime'], + ':MigratedContentHash' => $record['MigratedContentHash'] + ]); + $update->execute([ + ':MigratedContent' => $record['MigratedContent'], + ':OriginalUpdateTime' => $record['OriginalUpdateTime'], + ':Id' => $record['Id'], + ':OriginalContent' => $record['OriginalContent'] + ]); + if ($update->rowCount() !== 1) + throw new \RuntimeException('Content changed while the migration was running. No records were committed.'); + } + $this->connection->commit(); + } catch (\Throwable $exception) { + if ($this->connection->inTransaction()) + $this->connection->rollBack(); + throw $exception; + } + + return ['migrated' => count($prepared), 'skipped' => $skipped]; + } + + public function Rollback(int $batchSize = self::DEFAULT_BATCH_SIZE): array + { + $batchSize = $this->normalizeBatchSize($batchSize); + if (!$this->backupTableExists()) + return ['restored' => 0, 'modified' => 0]; + + $query = "SELECT B.ContentId, B.OriginalContent, B.OriginalUpdateTime, B.MigratedContentHash, C.Content AS CurrentContent + FROM `{$this->backupTable->Name}` AS B + INNER JOIN `{$this->contentTable->Name}` AS C ON C.Id=B.ContentId + WHERE B.RestoredTime IS NULL + ORDER BY B.Id DESC"; + $records = $this->connection->query($query)->fetchAll(\PDO::FETCH_ASSOC); + $restorable = []; + $modified = 0; + + foreach ($records as $record) { + if (!hash_equals((string)$record['MigratedContentHash'], hash('sha256', (string)$record['CurrentContent']))) { + $modified++; + continue; + } + $restorable[] = $record; + if (count($restorable) >= $batchSize) + break; + } + + if (!$restorable) + return ['restored' => 0, 'modified' => $modified]; + + $restore = $this->connection->prepare( + "UPDATE `{$this->contentTable->Name}` + SET Content=:OriginalContent, UpdateTime=:OriginalUpdateTime + WHERE Id=:ContentId AND Content=:CurrentContent" + ); + $mark = $this->connection->prepare( + "UPDATE `{$this->backupTable->Name}` SET RestoredTime=CURRENT_TIMESTAMP + WHERE ContentId=:ContentId AND RestoredTime IS NULL" + ); + + try { + $this->connection->beginTransaction(); + foreach ($restorable as $record) { + $restore->execute([ + ':OriginalContent' => $record['OriginalContent'], + ':OriginalUpdateTime' => $record['OriginalUpdateTime'], + ':ContentId' => $record['ContentId'], + ':CurrentContent' => $record['CurrentContent'] + ]); + if ($restore->rowCount() !== 1) + throw new \RuntimeException('Content changed while the rollback was running. No records were committed.'); + $mark->execute([':ContentId' => $record['ContentId']]); + } + $this->connection->commit(); + } catch (\Throwable $exception) { + if ($this->connection->inTransaction()) + $this->connection->rollBack(); + throw $exception; + } + + return ['restored' => count($restorable), 'modified' => $modified]; + } + + public function Convert(string $content): array + { + $analysis = $this->analyzeContent($content); + if (!$analysis['eligible']) + return ['html' => $content, ...$analysis]; + + return [...$this->convertAnalyzedContent($content, $analysis), ...$analysis]; + } + + private function analyzeContent(string $content): array + { + if (trim($content) === '') + return ['format' => 'empty', 'eligible' => false, 'issue' => null]; + + $hasHtml = preg_match('~]*)?>~iu', $content) === 1; + $hasMarkdown = preg_match( + '~(^|\R)\s{0,3}(?:#{1,6}\s+|(?:[-+*]|\d+[.)])\s+|>{1,3}\s+|\|.*\|\s*$|-{3,}\s*$)|!Button\[[^\]]*\]\([^\r\n]*\)|!\[[^\]]*\]\([^\r\n]*\)|\[[^\]]+\]\([^\r\n]*\)|(? 'unsupported', + 'eligible' => false, + 'issue' => 'Unsupported custom markup: ' . implode(', ', $names) + ]; + } + + if ($hasHtml && $hasMarkdown) + return ['format' => 'mixed', 'eligible' => false, 'issue' => 'HTML and Markdown are mixed in this record.']; + if ($hasHtml) + return ['format' => 'html', 'eligible' => false, 'issue' => null]; + + if ($this->containsUnsafeButtonUrl($content)) + return ['format' => 'unsupported', 'eligible' => false, 'issue' => 'A legacy button contains an unsupported URL.']; + + return ['format' => $hasMarkdown ? 'markdown' : 'plain', 'eligible' => true, 'issue' => null]; + } + + private function convertAnalyzedContent(string $content, array $analysis): array + { + if (!$analysis['eligible']) + return ['html' => $content]; + + $safeMarkup = preg_replace_callback( + '~!Button\[([^\]]*)\]\(\s*([^\s)]+)(?:\s+"[^"]*")?\s*\)~iu', + static fn(array $match) => '[' . $match[1] . '](' . $match[2] . ') @{output button}', + $content + ) ?? $content; + + $translationWasAllowed = \_::$Front->AllowTranslate; + try { + \_::$Front->AllowTranslate = false; + $html = Struct::Convert($safeMarkup); + } finally { + \_::$Front->AllowTranslate = $translationWasAllowed; + } + return ['html' => (string)TinyMCEContentSanitizer::Sanitize($html)]; + } + + private function containsUnsafeButtonUrl(string $content): bool + { + if (!preg_match_all('~!Button\[[^\]]*\]\(\s*([^\s)]+)~iu', $content, $matches)) + return false; + + foreach ($matches[1] as $url) + if (!preg_match('~^(?:https?://|/|#|mailto:|tel:)~iu', html_entity_decode((string)$url, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) + return true; + + return false; + } + + private function fetchContentRecords(): array + { + $query = "SELECT Id, Title, Content, UpdateTime FROM `{$this->contentTable->Name}` ORDER BY Id ASC"; + return $this->connection->query($query)->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + private function ensureBackupTable(): void + { + $this->connection->exec( + "CREATE TABLE IF NOT EXISTS `{$this->backupTable->Name}` ( + Id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ContentId BIGINT UNSIGNED NOT NULL, + Title VARCHAR(500) NULL, + OriginalContent LONGTEXT NULL, + OriginalUpdateTime DATETIME NULL, + MigratedContentHash CHAR(64) NOT NULL, + CreatedTime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + RestoredTime DATETIME NULL, + PRIMARY KEY (Id), + UNIQUE KEY UX_TinyMCE_ContentBackup_ContentId (ContentId), + KEY IX_TinyMCE_ContentBackup_RestoredTime (RestoredTime) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } + + private function backupTableExists(): bool + { + $statement = $this->connection->prepare( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=:TableName' + ); + $statement->execute([':TableName' => $this->backupTable->Name]); + return (int)$statement->fetchColumn() > 0; + } + + private function normalizeBatchSize(int $batchSize): int + { + return max(1, min(self::MAX_BATCH_SIZE, $batchSize)); + } + + private function assertTableName(string $tableName): void + { + if (!preg_match('/^[a-z0-9_]+$/i', $tableName)) + throw new \RuntimeException('The resolved database table name is invalid.'); + } +} diff --git a/route/administrator/tinymce/migration.php b/route/administrator/tinymce/migration.php new file mode 100644 index 0000000..540e1cb --- /dev/null +++ b/route/administrator/tinymce/migration.php @@ -0,0 +1,184 @@ +AdminAccess); + library('TinyMCEContentSanitizer'); + library('TinyMCEMarkdownMigrator'); + + $escape = static fn($value) => htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $translate = static fn(string $value) => __($value); + $formatLabels = [ + 'empty' => 'Empty', + 'plain' => 'Plain text', + 'markdown' => 'Markdown', + 'html' => 'Existing HTML', + 'mixed' => 'Mixed content', + 'unsupported' => 'Unsupported content' + ]; + $translateIssue = static function (?string $issue) use ($translate): string { + if (!$issue) + return ''; + if (str_starts_with($issue, 'Unsupported custom markup:')) + return $translate('Unsupported custom markup:') . substr($issue, strlen('Unsupported custom markup:')); + return $translate($issue); + }; + $message = null; + $messageClass = 'success'; + $migrator = new TinyMCEMarkdownMigrator(); + + if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { + $action = strtolower(trim((string)get($input, 'Action'))); + $confirmation = strtoupper(trim((string)get($input, 'Confirmation'))); + $batchSize = (int)get($input, 'BatchSize'); + + try { + if ($action === 'apply') { + if ($confirmation !== 'MIGRATE') + throw new RuntimeException('Type MIGRATE in the confirmation field before applying the migration.'); + + $result = $migrator->Apply($batchSize); + $message = sprintf( + $translate('%d content records were migrated. Run another batch until Eligible becomes zero.'), + $result['migrated'] + ); + } elseif ($action === 'rollback') { + if ($confirmation !== 'ROLLBACK') + throw new RuntimeException('Type ROLLBACK in the confirmation field before restoring content.'); + + $result = $migrator->Rollback($batchSize); + $message = sprintf( + $translate('%d content records were restored. %d edited records were protected and skipped.'), + $result['restored'], + $result['modified'] + ); + } + } catch (Throwable $exception) { + $message = $translate('The migration operation failed:') . ' ' . $exception->getMessage(); + $messageClass = 'error'; + } + } + + $summary = $migrator->Analyze(); + $stats = [ + 'Total content' => $summary['total'], + 'Eligible' => $summary['eligible'], + 'Markdown' => $summary['markdown'], + 'Plain text' => $summary['plain'], + 'Existing HTML' => $summary['html'], + 'Mixed content' => $summary['mixed'], + 'Unsupported content' => $summary['unsupported'], + 'Active backups' => $summary['activeBackups'], + 'Edited after migration' => $summary['modifiedAfterMigration'] + ]; + + ob_start(); + ?> + +
+

+

+ + +
+ + +
+
+ +
+ +
+ $value): ?> +
+ +
+ +
+
+

+

+ + + + + + +
+ +
+

+

+ + + + + + +
+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + +

+ +
+ AdminView)( + fn() => $renderMigration($input), + ['Image' => 'exchange', 'Title' => 'Markdown Content Migration'] + ); +}; + +(new Router())->if(\_::$User->HasAccess(\_::$User->AdminAccess)) + ->Get(fn() => $showMigration()) + ->Post(fn() => $showMigration(receivePost() ?: [])) + ->Default(fn() => response($renderMigration(receive() ?: []))) + ->Handle(); diff --git a/translations/PAS_TinyMCE_Translations_EN_FA.xlsx b/translations/PAS_TinyMCE_Translations_EN_FA.xlsx index 022dca7..6c0f50d 100644 Binary files a/translations/PAS_TinyMCE_Translations_EN_FA.xlsx and b/translations/PAS_TinyMCE_Translations_EN_FA.xlsx differ diff --git a/translations/tinymce-en-fa.csv b/translations/tinymce-en-fa.csv index 7db7845..7f8a2a6 100644 --- a/translations/tinymce-en-fa.csv +++ b/translations/tinymce-en-fa.csv @@ -81,3 +81,46 @@ EN,FA "Attach","پیوست" "Search","جست‌وجو" "Default","پیش‌فرض" +"Markdown Content Migration","مهاجرت محتوای مارک‌داون" +"Preview and convert legacy AseqBase Markdown content to sanitized HTML for TinyMCE. PHP code is never stored in content.","پیش‌نمایش و تبدیل محتوای قدیمی مارک‌داون AseqBase به HTML امن برای TinyMCE. هیچ کد PHP در محتوا ذخیره نمی‌شود." +"Safety notice","نکته ایمنی" +"Test this migration on a local database backup first. Mixed HTML and Markdown, unsupported directives, and content edited after migration are never overwritten automatically.","این مهاجرت را ابتدا روی نسخه پشتیبان دیتابیس محلی آزمایش کنید. محتوای ترکیبی HTML و مارک‌داون، دستورهای پشتیبانی‌نشده و محتوایی که پس از مهاجرت ویرایش شده باشد هرگز به‌صورت خودکار بازنویسی نمی‌شود." +"Total content","کل محتوا" +"Eligible","قابل مهاجرت" +"Markdown","مارک‌داون" +"Plain text","متن ساده" +"Existing HTML","HTML موجود" +"Mixed content","محتوای ترکیبی" +"Unsupported content","محتوای پشتیبانی‌نشده" +"Active backups","پشتیبان‌های فعال" +"Edited after migration","ویرایش‌شده پس از مهاجرت" +"Apply next migration batch","اجرای گروه بعدی مهاجرت" +"Original content and update time are saved in a dedicated backup table before each change.","پیش از هر تغییر، محتوای اصلی و زمان به‌روزرسانی در جدول پشتیبان اختصاصی ذخیره می‌شود." +"Batch size","اندازه گروه" +"Type MIGRATE to confirm","برای تأیید عبارت MIGRATE را وارد کنید" +"Apply migration batch","اجرای گروه مهاجرت" +"Rollback last migration batch","بازگردانی آخرین گروه مهاجرت" +"Records edited after migration are protected and will not be overwritten.","رکوردهایی که پس از مهاجرت ویرایش شده‌اند محافظت می‌شوند و بازنویسی نخواهند شد." +"Type ROLLBACK to confirm","برای تأیید عبارت ROLLBACK را وارد کنید" +"Restore from migration backup","بازیابی از پشتیبان مهاجرت" +"Dry-run samples","نمونه‌های پیش‌نمایش آزمایشی" +"No eligible or exceptional content records were found.","هیچ محتوای قابل مهاجرت یا استثنایی پیدا نشد." +"ID","شناسه" +"Detected format","قالب تشخیص‌داده‌شده" +"Original content","محتوای اصلی" +"Converted preview","پیش‌نمایش تبدیل‌شده" +"Skipped for manual review","برای بررسی دستی رد شد" +"Mixed","ترکیبی" +"Unsupported","پشتیبانی‌نشده" +"%d content records were migrated. Run another batch until Eligible becomes zero.","%d رکورد محتوا مهاجرت کرد. اجرای گروه‌ها را تا صفر شدن تعداد قابل مهاجرت ادامه دهید." +"%d content records were restored. %d edited records were protected and skipped.","%d رکورد محتوا بازیابی شد. %d رکورد ویرایش‌شده محافظت و رد شد." +"The migration operation failed:","عملیات مهاجرت ناموفق بود:" +"Type MIGRATE in the confirmation field before applying the migration.","پیش از اجرای مهاجرت عبارت MIGRATE را در فیلد تأیید وارد کنید." +"Type ROLLBACK in the confirmation field before restoring content.","پیش از بازیابی محتوا عبارت ROLLBACK را در فیلد تأیید وارد کنید." +"Empty","خالی" +"HTML and Markdown are mixed in this record.","در این رکورد HTML و مارک‌داون با هم ترکیب شده‌اند." +"A legacy button contains an unsupported URL.","یکی از دکمه‌های قدیمی دارای آدرس پشتیبانی‌نشده است." +"Unsupported custom markup:","نشانه‌گذاری سفارشی پشتیبانی‌نشده:" +"Content changed while the migration was running. No records were committed.","هنگام اجرای مهاجرت محتوا تغییر کرد. هیچ رکوردی ثبت نشد." +"Content changed while the rollback was running. No records were committed.","هنگام اجرای بازگردانی محتوا تغییر کرد. هیچ رکوردی ثبت نشد." +"The resolved database table name is invalid.","نام جدول دیتابیس معتبر نیست."