*/ public static function abilities(): array { $idSchema = ['minimum' => 1, 'type' => 'integer']; $fieldSchemas = [ 'content' => ['description' => 'Block markup or HTML for the whole content', 'type' => 'string'], 'excerpt' => ['type' => 'string'], 'parent' => ['minimum' => 0, 'type' => 'integer'], 'slug' => ['maxLength' => 200, 'type' => 'string'], 'status' => ['enum' => self::STATUSES, 'type' => 'string'], 'title' => ['maxLength' => 500, 'type' => 'string'], ]; return [ new Ability( 'sarcio/content-search', __('Look for pages and posts', 'sarcio'), Ability::READ, 'Search pages, posts and products by words in their title or content. Returns id, title, type, status, url, last modified and a short excerpt. Use it to find the id of what to change. Type wp_block is a synced pattern (a reusable block), searched only when asked for by type', [ 'properties' => [ 'limit' => ['maximum' => 20, 'minimum' => 1, 'type' => 'integer'], 'query' => ['maxLength' => 200, 'type' => 'string'], 'status' => ['enum' => ['any', 'publish', 'draft', 'pending', 'private'], 'type' => 'string'], 'type' => ['enum' => ['any', 'page', 'post', 'product', 'wp_block'], 'type' => 'string'], ], 'required' => [], 'type' => 'object', ], static fn (array $args): bool => current_user_can('edit_posts'), static fn (array $args): array => self::search($args), ), new Ability( 'sarcio/content-get', __('Read a page or post', 'sarcio'), Ability::READ, 'Read one page, post or product by id: title, status, slug, type, parent, template, excerpt and the full content as block markup or HTML. A page built with Elementor says builder: "elementor" and carries its text as builderText, one widget per line; a Divi page says builder: "divi" with its words in builderText; content-replace changes that text on both. Read before you change text, so the search string is exact.', ['properties' => ['id' => $idSchema], 'required' => ['id'], 'type' => 'object'], static fn (array $args): bool => isset($args['id']) ? current_user_can('edit_post', (int) $args['id']) : current_user_can('edit_posts'), static fn (array $args): array => self::get((int) $args['id']), ), new Ability( 'sarcio/patterns-list', __('List the theme’s layouts', 'sarcio'), Ability::READ, 'The block patterns this site offers (name, title, categories), for building a new page from the theme’s own layouts with content-create.', ['properties' => [], 'type' => 'object'], static fn (array $args): bool => current_user_can('edit_pages'), static fn (array $args): array => self::patterns(), ), new Ability( 'sarcio/content-update', __('Change a page or post', 'sarcio'), Ability::WRITE, 'Change the title, content, excerpt, slug, status or parent of an existing page, post or product. Only the fields given change. WordPress keeps a revision. Prefer content-replace for a small text change.', ['properties' => ['id' => $idSchema] + $fieldSchemas, 'required' => ['id'], 'type' => 'object'], static fn (array $args): bool => self::canEdit($args), static fn (array $args): array => self::update($args), null, static fn (array $before, array $after): array => self::restore($before), false, static fn (array $args, Overlay $overlay): array => self::stageUpdate($args, $overlay), ), new Ability( 'sarcio/content-replace', __('Change text on a page', 'sarcio'), Ability::WRITE, 'Replace an exact piece of text inside the content of a page, post or product, or inside the widgets of a page built with Elementor. Refused when the text is not found, or found more than once unless all is true. Read the content first and copy the text exactly.', [ 'properties' => [ 'all' => ['type' => 'boolean'], 'id' => $idSchema, 'replace' => ['maxLength' => 20000, 'type' => 'string'], 'search' => ['maxLength' => 20000, 'minLength' => 1, 'type' => 'string'], ], 'required' => ['id', 'search', 'replace'], 'type' => 'object', ], static fn (array $args): bool => self::canEdit($args), static fn (array $args): array => self::replace($args), null, static fn (array $before, array $after): array => self::restore($before), false, static fn (array $args, Overlay $overlay): array => self::stageReplace($args, $overlay), ), new Ability( 'sarcio/content-create', __('Create a page or post', 'sarcio'), Ability::WRITE, 'Create a new page or post. Give content as block markup or HTML, or patterns (names from patterns-list) to build it from the theme’s layouts. Saved as a draft unless status says otherwise.', [ 'properties' => [ 'content' => ['type' => 'string'], 'parent' => ['minimum' => 0, 'type' => 'integer'], 'patterns' => ['items' => ['maxLength' => 200, 'type' => 'string'], 'maxItems' => 20, 'type' => 'array'], 'slug' => ['maxLength' => 200, 'type' => 'string'], 'status' => ['enum' => ['draft', 'publish', 'pending', 'private'], 'type' => 'string'], 'title' => ['maxLength' => 500, 'minLength' => 1, 'type' => 'string'], 'type' => ['enum' => ['page', 'post'], 'type' => 'string'], ], 'required' => ['type', 'title'], 'type' => 'object', ], static fn (array $args): bool => self::canCreate($args), static fn (array $args): array => self::create($args), static fn (array $args): array => self::createPreview($args), static fn (array $before, array $after): array => self::trash($after), ), new Ability( 'sarcio/content-publish-at', __('Publish a page or post at a time', 'sarcio'), Ability::WRITE, 'Schedule a draft page, post or product to publish itself at a date and time in the site’s time zone (at: YYYY-MM-DDTHH:MM), or move an already scheduled one. WordPress publishes it then. Undo puts the previous status and date back.', ['properties' => ['at' => ['pattern' => '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$', 'type' => 'string'], 'id' => ['minimum' => 1, 'type' => 'integer']], 'required' => ['at', 'id'], 'type' => 'object'], static fn (array $args): bool => self::canEdit($args), static fn (array $args): array => self::publishAt($args), null, static fn (array $before, array $after): array => self::restoreTiming($before), ), ]; } /** * @return array{content?:string,count:int,error?:string} */ public static function replaceText(string $content, string $search, string $replace, bool $all): array { $replace = wp_kses_post($replace); $count = $search === '' ? 0 : substr_count($content, $search); if ($count === 0) { return ['count' => 0, 'error' => __('That text was not found on the page. Read the page and copy the text exactly.', 'sarcio')]; } if ($count > 1 && !$all) { return ['count' => $count, 'error' => sprintf( /* translators: %d: how many times the text appears. */ __('That text appears %d times on the page. Say which one, or ask to change all of them.', 'sarcio'), $count, )]; } return ['content' => str_replace($search, $replace, $content), 'count' => $count]; } /** * Page content as the owner reads it: block comments and tags removed, one * paragraph per line, so a change shows as words rather than markup. */ public static function readable(string $content): string { $text = (string) preg_replace('//s', '', $content); $text = (string) preg_replace('#|#i', "\n", $text); $text = html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $lines = array_filter(array_map('trim', explode("\n", $text)), static fn (string $line): bool => $line !== ''); return mb_substr(implode("\n", $lines), 0, 20000); } /** @return array */ public static function snapshot(object $post): array { return [ 'content' => (string) $post->post_content, 'excerpt' => (string) $post->post_excerpt, 'id' => (int) $post->ID, 'parent' => (int) $post->post_parent, 'slug' => (string) $post->post_name, 'status' => (string) $post->post_status, 'title' => (string) $post->post_title, 'type' => (string) $post->post_type, ]; } /** @param array $args */ private static function canEdit(array $args): bool { $id = (int) ($args['id'] ?? 0); if ($id <= 0) { return current_user_can('edit_posts'); } if (!current_user_can('edit_post', $id)) { return false; } if (($args['status'] ?? null) === 'publish') { $type = get_post_type($id); return current_user_can($type === 'page' ? 'publish_pages' : 'publish_posts'); } return true; } /** @param array $args */ private static function canCreate(array $args): bool { $page = ($args['type'] ?? 'page') === 'page'; if (!current_user_can($page ? 'edit_pages' : 'edit_posts')) { return false; } if (($args['status'] ?? 'draft') === 'publish') { return current_user_can($page ? 'publish_pages' : 'publish_posts'); } return true; } private static function filterContent(string $content): string { return wp_kses_post($content); } private static function keepContent(string $content): string { return current_user_can('unfiltered_html') ? $content : wp_kses_post($content); } private static function summarize(object $post): array { return [ 'excerpt' => mb_substr(wp_strip_all_tags((string) ($post->post_excerpt !== '' ? $post->post_excerpt : $post->post_content)), 0, 200), 'id' => (int) $post->ID, 'modified' => (string) $post->post_modified_gmt, 'status' => (string) $post->post_status, 'title' => (string) $post->post_title, 'type' => (string) $post->post_type, 'url' => (string) get_permalink($post), ]; } /** @param array $args */ private static function search(array $args): array { $type = (string) ($args['type'] ?? 'any'); $types = $type === 'any' ? array_values(array_filter(self::TYPES, static fn (string $one): bool => $one !== 'wp_block' && ($one !== 'product' || SiteAbilities::woocommerceActive()))) : [$type]; $query = [ 'numberposts' => (int) ($args['limit'] ?? 10), 'orderby' => isset($args['query']) && $args['query'] !== '' ? 'relevance' : 'modified', 'post_status' => (string) ($args['status'] ?? 'any'), 'post_type' => $types, 's' => (string) ($args['query'] ?? ''), ]; $posts = is_array(get_posts($query)) ? get_posts($query) : []; if (Stage::active()) { $overlay = Stage::overlay(); $posts = array_map(static fn (object $post): object => $overlay->applyToPost($post), $posts); } return ['results' => array_map([self::class, 'summarize'], $posts)]; } private static function get(int $id): array { $post = Stage::post($id); if ($post === null || !in_array((string) $post->post_type, self::TYPES, true)) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } $content = (string) $post->post_content; $truncated = mb_strlen($content) > self::MAX_CONTENT_CHARS; $overlay = Stage::active() ? Stage::overlay() : null; $builder = Builders::of((int) $post->ID, $overlay); return self::snapshot($post) + [ 'template' => (string) get_page_template_slug($post), 'truncated' => $truncated, 'url' => (string) get_permalink($post), ] + ($truncated ? ['content' => mb_substr($content, 0, self::MAX_CONTENT_CHARS)] : []) + ($builder === Builders::ELEMENTOR ? ['builder' => $builder, 'builderText' => Builders::text((int) $post->ID, $overlay)] : []) + ($builder === Builders::DIVI ? ['builder' => $builder, 'builderText' => Builders::diviText($content)] : []); } private static function patterns(): array { if (!class_exists('WP_Block_Patterns_Registry')) { return ['patterns' => []]; } $all = \WP_Block_Patterns_Registry::get_instance()->get_all_registered(); $out = []; foreach (array_slice($all, 0, 60) as $pattern) { $out[] = [ 'categories' => array_values((array) ($pattern['categories'] ?? [])), 'name' => (string) ($pattern['name'] ?? ''), 'title' => (string) ($pattern['title'] ?? ''), ]; } return ['patterns' => $out]; } /** @param array $args */ private static function changes(array $args, object $post): array { $before = self::snapshot($post); $after = $before; foreach (self::FIELDS as $field) { if (array_key_exists($field, $args)) { $after[$field] = match ($field) { 'content' => self::filterContent((string) $args[$field]), 'excerpt' => wp_kses_post((string) $args[$field]), 'title' => sanitize_text_field((string) $args[$field]), default => $args[$field], }; } } return [$before, $after]; } /** @param array $args */ private static function update(array $args): array { $post = get_post((int) $args['id']); if ($post === null) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } [$before, $after] = self::changes($args, $post); if ($before === $after) { return ['error' => __('Nothing would change.', 'sarcio')]; } $result = self::write($after); if (isset($result['error'])) { return $result; } $changed = array_keys(array_filter($after, static fn ($value, $key) => $before[$key] !== $value, ARRAY_FILTER_USE_BOTH)); return [ 'changed' => $changed, 'id' => (int) $post->ID, 'journal' => [ 'after' => $after, 'before' => $before, 'summary' => sprintf( /* translators: 1: the fields changed, 2: the page title. */ __('Changed %1$s of “%2$s”', 'sarcio'), implode(', ', $changed), $before['title'], ), 'target' => 'post:' . $post->ID, ], 'url' => (string) get_permalink($post), ]; } /** @param array $args */ private static function stageUpdate(array $args, Overlay $overlay): array { $post = $overlay->post((int) $args['id']); if ($post === null) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } [$before, $after] = self::changes($args, $post); $changed = array_keys(array_filter($after, static fn ($value, $key) => $before[$key] !== $value, ARRAY_FILTER_USE_BOTH)); if ($changed === []) { return ['error' => __('Nothing would change.', 'sarcio')]; } $overlay->setPost((int) $post->ID, self::postFields($after)); return [ 'after' => self::describe($after, $changed), 'before' => self::describe($before, $changed), 'summary' => sprintf( /* translators: 1: the fields changed, 2: the page title. */ __('Change the %1$s of “%2$s”', 'sarcio'), implode(', ', $changed), $before['title'], ), ]; } /** @param array $args */ private static function replace(array $args): array { $post = get_post((int) $args['id']); if ($post === null) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } if (Builders::of((int) $post->ID) === Builders::ELEMENTOR) { return self::replaceInBuilder($post, $args); } $result = self::replaceText((string) $post->post_content, (string) $args['search'], (string) $args['replace'], (bool) ($args['all'] ?? false)); if (isset($result['error'])) { return ['error' => $result['error']]; } $before = self::snapshot($post); $after = ['content' => self::keepContent($result['content'])] + $before; $written = self::write($after); if (isset($written['error'])) { return $written; } return [ 'id' => (int) $post->ID, 'journal' => [ 'after' => $after, 'before' => $before, 'summary' => sprintf( /* translators: 1: the old text, 2: the new text, 3: the page title. */ __('Changed “%1$s” to “%2$s” on “%3$s”', 'sarcio'), mb_substr((string) $args['search'], 0, 60), mb_substr((string) $args['replace'], 0, 60), $before['title'], ), 'target' => 'post:' . $post->ID, ], 'replaced' => $result['count'], 'url' => (string) get_permalink($post), ]; } /** @param array $args */ private static function stageReplace(array $args, Overlay $overlay): array { $post = $overlay->post((int) $args['id']); if ($post === null) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } if (Builders::of((int) $post->ID, $overlay) === Builders::ELEMENTOR) { $before = Builders::text((int) $post->ID, $overlay); $result = Builders::replace((int) $post->ID, (string) $args['search'], (string) $args['replace'], (bool) ($args['all'] ?? false), $overlay); if (isset($result['error'])) { return ['error' => $result['error']]; } $overlay->setMeta((int) $post->ID, Builders::ELEMENTOR_DATA, Builders::encode($result['data'])); $overlay->setMeta((int) $post->ID, Builders::ELEMENTOR_CACHE, ''); return [ 'after' => Builders::text((int) $post->ID, $overlay), 'before' => $before, 'summary' => self::replaceSummary($result['count'], (string) $post->post_title), ]; } $result = self::replaceText((string) $post->post_content, (string) $args['search'], (string) $args['replace'], (bool) ($args['all'] ?? false)); if (isset($result['error'])) { return ['error' => $result['error']]; } $overlay->setPost((int) $post->ID, ['post_content' => self::keepContent((string) $result['content'])]); return [ 'after' => self::readable((string) $result['content']), 'before' => self::readable((string) $post->post_content), 'summary' => self::replaceSummary($result['count'], (string) $post->post_title), ]; } private static function replaceSummary(int $count, string $title): string { return $count > 1 ? sprintf( /* translators: 1: how many times, 2: the page title. */ __('Change this text in %1$d places on “%2$s”', 'sarcio'), $count, $title, ) : sprintf( /* translators: %s: the page title. */ __('Change this text on “%s”', 'sarcio'), $title, ); } /** * The same exact-text change inside a page builder's data, journaled with * the builder's whole tree so undo puts every widget back. * * @param array $args */ private static function replaceInBuilder(object $post, array $args): array { $id = (int) $post->ID; $before = Builders::data($id) ?? []; $result = Builders::replace($id, (string) $args['search'], (string) $args['replace'], (bool) ($args['all'] ?? false)); if (isset($result['error'])) { return ['error' => $result['error']]; } if (!Builders::write($id, $result['data'])) { return ['error' => __('WordPress could not save the change.', 'sarcio')]; } return [ 'builder' => Builders::ELEMENTOR, 'id' => $id, 'journal' => [ 'after' => ['builder' => Builders::ELEMENTOR, 'builderData' => $result['data'], 'id' => $id, 'title' => (string) $post->post_title], 'before' => ['builder' => Builders::ELEMENTOR, 'builderData' => $before, 'id' => $id, 'title' => (string) $post->post_title], 'summary' => sprintf( /* translators: 1: the old text, 2: the new text, 3: the page title. */ __('Changed “%1$s” to “%2$s” on “%3$s”', 'sarcio'), mb_substr((string) $args['search'], 0, 60), mb_substr((string) $args['replace'], 0, 60), (string) $post->post_title, ), 'target' => 'post:' . $id, ], 'replaced' => $result['count'], 'url' => (string) get_permalink($post), ]; } /** @param array $args */ private static function create(array $args): array { $content = self::composeContent($args); if (isset($content['error'])) { return $content; } $data = [ 'post_content' => self::filterContent($content['content']), 'post_name' => (string) ($args['slug'] ?? ''), 'post_parent' => (int) ($args['parent'] ?? 0), 'post_status' => (string) ($args['status'] ?? 'draft'), 'post_title' => sanitize_text_field((string) $args['title']), 'post_type' => (string) $args['type'], ]; $id = wp_insert_post($data, true); if (is_wp_error($id) || !is_int($id) || $id <= 0) { return ['error' => __('WordPress could not create it.', 'sarcio')]; } return [ 'id' => $id, 'journal' => [ 'after' => ['id' => $id, 'status' => $data['post_status'], 'title' => $data['post_title'], 'type' => $data['post_type']], 'before' => [], 'summary' => sprintf( /* translators: 1: page or post, 2: its title. */ __('Created the %1$s “%2$s”', 'sarcio'), $data['post_type'], $data['post_title'], ), 'target' => 'post:' . $id, ], 'status' => $data['post_status'], 'url' => (string) get_permalink($id), ]; } /** @param array $args */ private static function createPreview(array $args): array { $content = self::composeContent($args); if (isset($content['error'])) { return $content; } $status = (string) ($args['status'] ?? 'draft'); return [ 'after' => mb_substr(wp_strip_all_tags($content['content']), 0, 1500), 'before' => '', 'summary' => sprintf( /* translators: 1: page or post, 2: its title, 3: draft or publish. */ __('Create the %1$s “%2$s” (%3$s)', 'sarcio'), (string) $args['type'], (string) $args['title'], $status === 'publish' ? __('published right away', 'sarcio') : __('as a draft', 'sarcio'), ), ]; } /** * @param array $args * @return array{content?:string,error?:string} */ private static function composeContent(array $args): array { $content = (string) ($args['content'] ?? ''); $patterns = (array) ($args['patterns'] ?? []); if ($patterns === []) { return ['content' => $content]; } if (!class_exists('WP_Block_Patterns_Registry')) { return ['error' => __('This site has no layouts to build from.', 'sarcio')]; } $registry = \WP_Block_Patterns_Registry::get_instance(); $parts = []; foreach ($patterns as $name) { $pattern = $registry->get_registered((string) $name); if ($pattern === null) { return ['error' => sprintf( /* translators: %s: the layout name. */ __('There is no layout called “%s”.', 'sarcio'), (string) $name, )]; } $parts[] = (string) ($pattern['content'] ?? ''); } $parts[] = $content; return ['content' => implode("\n\n", array_filter($parts, static fn (string $part): bool => $part !== ''))]; } /** * @param array $fields the snapshot shape * @return array WP_Post field names */ private static function postFields(array $fields): array { return [ 'post_content' => (string) $fields['content'], 'post_excerpt' => (string) $fields['excerpt'], 'post_name' => (string) $fields['slug'], 'post_parent' => (int) $fields['parent'], 'post_status' => (string) $fields['status'], 'post_title' => (string) $fields['title'], ]; } /** @param array $args */ private static function publishAt(array $args): array { $post = get_post((int) $args['id']); if ($post === null) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } if ((string) $post->post_status === 'publish') { return ['error' => __('That is already published. Unpublish it first if you want it to go out later.', 'sarcio')]; } $timestamp = Scheduled::parse((string) $args['at']); if ($timestamp === null) { return ['error' => __('That is not a date and time I can read (use YYYY-MM-DDTHH:MM).', 'sarcio')]; } if ($timestamp <= time()) { return ['error' => __('That time has already passed; use content-update with status publish to publish now.', 'sarcio')]; } $before = ['date' => (string) $post->post_date, 'dateGmt' => (string) ($post->post_date_gmt ?? ''), 'id' => (int) $post->ID, 'status' => (string) $post->post_status]; $result = wp_update_post(['ID' => (int) $post->ID, 'post_date' => wp_date('Y-m-d H:i:s', $timestamp, wp_timezone()), 'post_date_gmt' => gmdate('Y-m-d H:i:s', $timestamp), 'post_status' => 'future'], true); if (is_wp_error($result) || (int) $result <= 0) { return ['error' => __('WordPress could not schedule it.', 'sarcio')]; } $when = wp_date('Y-m-d H:i', $timestamp, wp_timezone()); return [ 'journal' => [ 'after' => ['at' => $when, 'id' => (int) $post->ID, 'status' => 'future'], 'before' => $before, 'summary' => sprintf( /* translators: 1: the page title, 2: the date and time. */ __('Scheduled “%1$s” to publish at %2$s', 'sarcio'), (string) $post->post_title, $when, ), 'target' => 'post:' . $post->ID, ], 'publishesAt' => $when, ]; } /** @param array $before */ private static function restoreTiming(array $before): array { $id = (int) ($before['id'] ?? 0); if ($id <= 0 || get_post($id) === null) { return ['error' => __('That page no longer exists.', 'sarcio')]; } $data = ['ID' => $id, 'post_status' => (string) ($before['status'] ?? 'draft')]; if (($before['date'] ?? '') !== '') { $data['post_date'] = (string) $before['date']; $data['post_date_gmt'] = (string) ($before['dateGmt'] ?? ''); } $result = wp_update_post($data, true); return is_wp_error($result) ? ['error' => sanitize_text_field($result->get_error_message())] : ['restored' => true]; } /** @param array $fields */ private static function write(array $fields): array { $result = wp_update_post([ 'ID' => (int) $fields['id'], 'post_content' => (string) $fields['content'], 'post_excerpt' => (string) $fields['excerpt'], 'post_name' => (string) $fields['slug'], 'post_parent' => (int) $fields['parent'], 'post_status' => (string) $fields['status'], 'post_title' => (string) $fields['title'], ], true); if (is_wp_error($result) || (int) $result <= 0) { return ['error' => __('WordPress could not save the change.', 'sarcio')]; } if (Builders::of((int) $result) === Builders::DIVI) { Builders::clearDiviCache((int) $result); } return ['id' => (int) $result]; } /** @param array $before */ private static function restore(array $before): array { if (!isset($before['id']) || get_post((int) $before['id']) === null) { return ['error' => __('That page no longer exists.', 'sarcio')]; } if (($before['builder'] ?? null) === Builders::ELEMENTOR && is_array($before['builderData'] ?? null)) { if (!Builders::write((int) $before['id'], $before['builderData'])) { return ['error' => __('WordPress could not restore the page.', 'sarcio')]; } return ['id' => (int) $before['id'], 'restored' => true]; } $written = self::write($before); if (isset($written['error'])) { return $written; } return ['id' => (int) $before['id'], 'restored' => true]; } /** @param array $after */ private static function trash(array $after): array { $id = (int) ($after['id'] ?? 0); if ($id <= 0 || get_post($id) === null) { return ['error' => __('That page no longer exists.', 'sarcio')]; } $result = wp_trash_post($id); if (!$result) { return ['error' => __('WordPress could not move it to the bin.', 'sarcio')]; } return ['id' => $id, 'trashed' => true]; } /** * @param array $fields * @param array $changed */ private static function describe(array $fields, array $changed): string { $lines = []; foreach ($changed as $field) { $value = (string) $fields[$field]; if (in_array($field, ['content', 'excerpt'], true)) { $value = self::readable($value); } $lines[] = count($changed) === 1 ? $value : $field . ': ' . $value; } return mb_substr(implode("\n", $lines), 0, 20000); } }