Forum Replies Created

Viewing 15 replies - 1 through 15 (of 110 total)
  • Thread Starter thomask

    (@thomask)

    solved for excerpt (new hook added and copied) and changeg prompt to textarea

    <?php
    /*
    Plugin Name: AI Translate For Polylang
    Plugin URI: https://wordpress.org/plugins/ai-translate-polylang/
    Description: Add auto AI translation caperbility to Polylang
    Version: 1.1.4
    Author: James Low
    Author URI: http://jameslow.com
    License: MIT License
    */

    /*
    Next Version:
    - Auto translate on publish post (publish/save as draft)
    - Setting to only auto translate for certain categories/pages
    */

    namespace AI_Translate_Polylang;

    class AI_Translate_Polylang {
    //Constants
    public static $PROMPT = 'Translate the content from {FROM_CODE} to {TO_CODE} preserving html, formatting and embedded media. Only return the new content.';
    public static $OPENAI_MODEL = 'gpt-4o';
    public static $CLAUDE_MODEL = 'claude-3-5-sonnet-20240620';
    public static $GEMINI_MODEL = 'gemini-2.5-flash';

    //Variables
    public static $meta_translate;
    public static $meta_clear;

    /* Helper functions */
    public static function require_settings() {
    if (class_exists('\PageApp')) {
    \PageApp::require_settings();
    } else {
    require_once 'inc/settingslib.php';
    }
    }
    public static function require_utils() {
    if (class_exists('\PageApp')) {
    \PageApp::require_utils();
    } else {
    require_once 'inc/utilslib.php';
    }
    }
    public static function require_openai() {
    require_once 'inc/open-ai/Url.php';
    require_once 'inc/open-ai/OpenAi.php';
    }

    /* Hooks */
    public static function add_hooks() {
    add_action('init', array(static::class, 'init'), 11);
    add_filter('default_title', array(static::class, 'default_title'), 10, 2);
    add_filter('default_content', array(static::class, 'default_content'), 10, 2);
    add_filter('default_excerpt', array(static::class, 'default_excerpt'), 10, 2);
    //add_filter('pll_copy_post_metas', array(static::class, 'pll_copy_post_metas'), 11, 5); //This gets called, but doesn't clear out keys
    add_filter('pll_translate_post_meta', array(static::class, 'pll_translate_post_meta'), 10, 3);
    }
    public static function init() {
    self::require_settings();
    $settings = new \SettingsLib(array(
    array('id'=>'ai_translate_new_post', 'type'=>'boolean', 'title'=>'Auto Translate New Translation Posts', 'default'=>'1'),
    array('id'=>'ai_translate_prompt', 'type'=>'text', 'title'=>'Custom Prompt', 'description'=>'{FROM_CODE} and {TO_CODE} will be replaced by from and to languages.', 'default'=>self::$PROMPT),
    array('id'=>'ai_translate_llm', 'type'=>'select', 'title'=>'LLM Service', 'default'=>'OpenAI', 'values'=>array(
    'OpenAI',
    'Claude',
    'Gemini'
    )),
    array('id'=>'ai_translate_openai', 'type'=>'title', 'title'=>'OpenAI', 'description'=>''),
    array('id'=>'ai_translate_openai_key', 'type'=>'string', 'title'=>'OpenAI API Key', 'description'=>''),
    array('id'=>'ai_translate_openai_org', 'type'=>'string', 'title'=>'OpenAI Organization', 'description'=>'(Optional)'),
    array('id'=>'ai_translate_openai_model', 'type'=>'string', 'title'=>'OpenAI Model', 'default'=>self::$OPENAI_MODEL, 'values'=>array(
    'gpt-4o',
    'gpt-4o-mini',
    'gpt-4-turbo',
    'gpt-4',
    'gpt-3.5-turbo'
    )),
    array('id'=>'ai_translate_claude', 'type'=>'title', 'title'=>'Claude', 'description'=>''),
    array('id'=>'ai_translate_claude_key', 'type'=>'string', 'title'=>'Claude API Key', 'description'=>''),
    array('id'=>'ai_translate_claude_model', 'type'=>'string', 'title'=>'OpenAI Model', 'default'=>self::$CLAUDE_MODEL, 'values'=>array(
    'claude-3-5-sonnet-20240620',
    'claude-3-opus-20240229',
    'claude-3-sonnet-20240229',
    'claude-3-haiku-20240307'
    )),
    array('id'=>'ai_translate_gemini', 'type'=>'title', 'title'=>'Gemini', 'description'=>''),
    array('id'=>'ai_translate_gemini_key', 'type'=>'string', 'title'=>'Gemini API Key', 'description'=>''),
    array('id'=>'ai_translate_gemini_model', 'type'=>'string', 'title'=>'Gemini Model', 'default'=>self::$GEMINI_MODEL, 'values'=>array(
    'gemini-2.5-flash',
    'gemini-2.0-flash',
    'gemini-1.5-flash',
    'gemini-1.5-pro'
    )),
    array('id'=>'ai_translate_meta', 'type'=>'title', 'title'=>'Meta', 'description'=>''),
    array('id'=>'ai_translate_meta_clear', 'type'=>'text', 'title'=>'Meta keys to clear', 'description'=>'', 'default'=>''),
    array('id'=>'ai_translate_meta_translate', 'type'=>'text', 'title'=>'Meta keys to translate', 'description'=>'', 'default'=>''),
    ), 'AI Translate', 'mlang', false, 'manage_options', null, null, '', '_');
    }
    public static function default_title($title, $post) {
    $pattern = '/[^\p{L}\p{N}]+$/u'; //Remove trailing not alpha numeric characters
    return preg_replace($pattern, '', wp_strip_all_tags(self::translate_field($title, 'post_title')));
    }
    public static function default_content($content, $post) {
    return self::translate_field($content, 'post_content');
    }
    public static function default_excerpt($excerpt, $post) {
    return self::translate_field($excerpt, 'post_excerpt');
    }

    public static function pll_copy_post_metas($keys, $sync, $from, $to, $lang) {
    $keys = array_diff($keys, self::meta_clear());
    return $keys;
    }
    public static function pll_translate_post_meta($value, $key, $lang) {
    if (in_array($key, self::meta_clear())) {
    $value = '';
    } else if (in_array($key, self::meta_translate())) {
    $value = self::translate_field($value, $key, true);
    }
    return $value;
    }
    private static function meta_keys($option) {
    $clear = get_option($option);
    if ($clear) {
    return preg_split('/\s+/', $clear);
    } else {
    return array();
    }
    }
    private static function meta_clear() {
    if (!self::$meta_clear) {
    self::$meta_clear = self::meta_keys('ai_translate_meta_clear');
    }
    return self::$meta_clear;
    }
    private static function meta_translate(){
    if (!self::$meta_translate) {
    self::$meta_translate = self::meta_keys('ai_translate_meta_translate');
    }
    return self::$meta_translate;
    }

    /* Translation */
    public static function translate_field($original, $field = '', $meta = false) {
    $translation = null;
    if (get_option('ai_translate_new_post', '0') == '1' && isset($_GET['new_lang']) && $_GET['new_lang'] && isset($_GET['from_post'])) {
    if (!$original || $original != '') {
    $to = sanitize_key($_GET['new_lang']);
    $post_id = sanitize_key($_GET['from_post']);
    if ($field) {
    if ($meta) {
    $original = get_post_meta($post_id, $field, true);
    } else {
    $post = get_post($post_id);
    $original = $post->$field;
    }
    }
    }
    $translation = self::translate($original, $to, pll_get_post_language($post_id));
    } else {
    $translation = $original;
    }
    return $translation;
    }
    public static function prompt($to, $from = 'en') {
    return str_replace('{TO_CODE}', $to, str_replace('{FROM_CODE}', $from, get_option('ai_translate_prompt', self::$PROMPT)));
    }
    public static function translate($text, $to, $from = 'en') {
    if ($text && trim($text) != '') {
    $prompt = self::prompt($to, $from);
    $service = get_option('ai_translate_llm', 'OpenAI');
    if ($service == 'Claude') {
    $result = self::claude_api($text, $prompt);
    $body = json_decode($result['body'], true);
    if ($result['response']['code'] == 200) {
    return $body['content'][0]['text'];
    } else {
    return 'ERROR: '.$body['error']['message'];
    }
    } elseif ($service == 'Gemini') {
    $result = self::gemini_api($text, $prompt);
    $body = json_decode($result['body'], true);
    if ($result['response']['code'] == 200) {
    return $body['candidates'][0]['content']['parts'][0]['text'];
    } else {
    return 'ERROR: '.$body['error']['message'];
    }
    } else {
    $result = self::openai_api($text, $prompt);
    if (!isset($result['error'])) {
    $translation = $result['choices'][0]['message']['content'];
    } else {
    $translation = 'ERROR: '.$result['error']['message'];
    }
    return $translation;
    }
    } else {
    return '';
    }
    }
    public static $author = 0;
    public static function wp_insert_post_data($data , $postarr) {
    $data['post_author'] = self::$author;
    self::$author = 0;
    remove_filter('wp_insert_post_data', array(static::class, 'wp_insert_post_data'), 99);
    return $data;
    }
    public static function translate_post($post_id, $to, $status = 'publish', $overwrite = false) {
    $from = pll_get_post_language($post_id);
    $translation = pll_get_post($post_id, $to);
    if (!$translation || get_post_status($translation) === false || $overwrite) {
    $post = get_post($post_id);

    self::$author = $post->post_author;
    add_filter('wp_insert_post_data', array(static::class, 'wp_insert_post_data'), '99', 2);
    if (!$translation) {
    // Create a new post with the same content
    $translation = wp_insert_post([
    'post_status' => 'draft',
    'post_title' => $post->post_title." ($to)",
    'post_content' => ' ',
    'post_type' => $post->post_type,
    'post_author' => $post->post_author, //Wordpress overrides author to 0, hence hook
    'post_date' => $post->post_date,
    'post_date_gmt' => $post->post_date_gmt,
    'post_modified' => $post->post_modified,
    'post_modified_gmt' => $post->post_modified_gmt,
    'post_excerpt' => $post->post_excerpt
    ]);
    // Set the language for the new post first in case something goes wrong
    pll_set_post_language($translation, $to);
    }

    wp_update_post(array(
    'ID' => $translation,
    'post_title' => self::translate($post->post_title, $to, $from),
    'post_content' => self::translate($post->post_content, $to, $from),
    'post_excerpt' => self::translate($post->post_excerpt, $to, $from),
    'post_status' => $status
    ));

    // Duplicate post meta
    $meta = get_post_meta($post_id);
    foreach ($meta as $key => $values) {
    foreach ($values as $value) {
    $value = maybe_unserialize($value);
    if (in_array($key, self::meta_clear())) {
    $value = '';
    } else if (in_array($key, self::meta_translate())) {
    $value = self::translate($value, $to, $from);
    }
    add_post_meta($translation, $key, $value);
    }
    }

    //TODO: Should we use PLL_Sync_Tax->copy($from,$to, $lang)
    // Get the translated term IDs for categories
    $categories = get_the_category($post_id);
    $translated_category_ids = [];
    foreach ($categories as $category) {
    $translated_category_id = pll_get_term($category->term_id, $to);
    if ($translated_category_id) {
    $translated_category_ids[] = $translated_category_id;
    }
    }
    wp_set_post_categories($translation, $translated_category_ids);

    // Get the translated term IDs for tags
    $tags = wp_get_post_tags($post_id);
    $translated_tag_ids = [];
    foreach ($tags as $tag) {
    $translated_tag_id = pll_get_term($tag->term_id, $to);
    if ($translated_tag_id) {
    $translated_tag_ids[] = $translated_tag_id;
    }
    }
    wp_set_post_tags($translation, $translated_tag_ids);

    // Link the new translation with the original post
    pll_save_post_translations([
    $to => $translation,
    pll_get_post_language($post_id) => $post_id,
    ]);
    }
    return $translation;
    }

    /* APIs */
    public static function claude_api($content, $role = 'You are a helpful assistant.', $tokens = 5000, $temp = 0) {
    $request = new \WP_Http();
    $headers = array(
    'x-api-key' => get_option('ai_translate_claude_key', ''),
    'anthropic-version' => '2023-06-01',
    'Content-Type' => 'application/json',
    );
    $message = array(
    'model' => get_option('ai_translate_claude_model', self::$CLAUDE_MODEL),
    'max_tokens' => $tokens,
    'temperature' => $temp,
    'system' => $role,
    'messages' => array(
    array("role" => 'user', "content" => $content)
    )
    );
    $args = array(
    'method' => 'POST',
    'headers' => $headers,
    'body' => json_encode($message),
    'timeout' => 60,
    );
    return $request->request('https://api.anthropic.com/v1/messages', $args);
    //https://www.datacamp.com/tutorial/getting-started-with-claude-3-and-the-claude-3-api
    //return $request->request('https://api.anthropic.com/v1/complete', $args);
    }
    public static function openai() {
    self::require_openai();
    //Create new open ai every time, otherwise it preserves conversation between calls and gets confused translating title/content
    $openai = new \Orhanerday\OpenAi\OpenAi(get_option('ai_translate_openai_key', ''));
    if ($org = get_option('ai_translate_openai_org')) {
    $openai->setORG($org);
    }
    return $openai;
    }
    public static function openai_api($content, $role = 'You are a helpful assistant.', $tokens = 5000) {
    //https://packagist.org/packages/orhanerday/open-ai
    return json_decode(self::openai()->chat([
    'model' => get_option('ai_translate_openai_model', self::$OPENAI_MODEL),
    'messages' => [
    [
    "role" => "system",
    "content" => "$role"
    ],
    [
    "role" => "user",
    "content" => "$content"
    ]
    ],
    'temperature' => 1.0,
    'max_tokens' => $tokens,
    'frequency_penalty' => 0,
    'presence_penalty' => 0,
    ]), true);
    }
    public static function gemini_api($content, $role = 'You are a helpful assistant.') {
    //https://ai.google.dev/gemini-api/docs/text-generation#rest_2
    $request = new \WP_Http();
    $headers = array(
    'Content-Type' => 'application/json',
    );
    $message = array(
    'system_instruction' => array(
    'parts' => array(
    array('text' => $role)
    )
    ),
    'contents' => array(
    'parts' => array(
    array('text' => $content)
    )
    )
    );
    $args = array(
    'method' => 'POST',
    'headers' => $headers,
    'body' => json_encode($message),
    'timeout' => 60,
    );
    return $request->request('https://generativelanguage.googleapis.com/v1beta/models/'.get_option('ai_translate_gemini_model', self::$GEMINI_MODEL).':generateContent?key='.get_option('ai_translate_gemini_key', ''), $args);
    }
    }

    AI_Translate_Polylang::add_hooks();
    Thread Starter thomask

    (@thomask)

    This issue is 5 months old and still not solved. Any news?

    Thread Starter thomask

    (@thomask)

    yep, i know, thank you, i just wanted you to be aware of them, no prob for me.

    Thread Starter thomask

    (@thomask)

    And some more

    Deprecated: Creation of dynamic property Google\Site_Kit\Core\Authentication\Setup::$proxy_support_link_url is deprecated inΒ /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/google-site-kit/includes/Core/Authentication/Setup.phpΒ on lineΒ 94

    Deprecated: stripos(): Passing null to parameter #1 ($haystack) of type string is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/google-site-kit/includes/Core/REST_API/REST_Routes.php on line 69

    Deprecated: Creation of dynamic property Google\Site_Kit\Modules\Analytics\Web_Tag::$module_slug is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/google-site-kit/includes/Core/Modules/Tags/Module_Tag.php on line 42

    Deprecated: Creation of dynamic property Google\Site_Kit\Modules\Analytics_4\Web_Tag::$module_slug is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/google-site-kit/includes/Core/Modules/Tags/Module_Tag.php on line 42

    Thread Starter thomask

    (@thomask)

    and

    Deprecated: Creation of dynamic property DeepLApiUsage::$languages is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/wpdeepl/client/deeplapi.class.php on line 35

    Deprecated: Creation of dynamic property DeepLApiUsage::$doing_cron is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/wpdeepl/client/deeplapi.class.php on line 41

    Deprecated: Creation of dynamic property DeepLApiUsage::$instance is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/wpdeepl/client/deeplapi.class.php on line 44

    Deprecated: Creation of dynamic property DeepLApiUsage::$result is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/wpdeepl/client/deeplapi.class.php on line 184

    Deprecated: Creation of dynamic property DeepLApiUsage::$why_no_cache is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/wpdeepl/client/deepl-data.class.php on line 8

    Deprecated: Creation of dynamic property DeepLApiUsage::$final_request is deprecated in /var/www/vhosts/powerbox.one/httpdocs/wp-content/plugins/wpdeepl/client/deeplapi.class.php on line 98

    What??? Give it back. Now.

    There aare many reasons, why shortcodes should be allowed in templates. One of them for me is that they can be inline, something, what is not valid for any other blocks. So e. g. On one of my webs I have a short code [total], that shows total number of my listing’s, and I can then put it in any sentence, any paragraph, heading etc, like “we do have [total] listings. There is currently now other easy way how to do it in WordPress.

    I uunderstand, that often plugin authors use shortcodes instead of blocks, and it destroys the block wysiwig feeling for the users. And I am totally open to find a solution for this problem. But not without discussing by breaking functionality on live websites in third level update. This is insane.

    BTW you should ask, why plugin authors do it this way. You will probably find out, that they simply do not want to learn React, as they are whole live PHP programmers. Make an easy way, how to create wysiwig block, without need to use node, learning JS etc…. Simply the same way how they can do it now with shortcodes with one simple short function or with some admininstation, and they will not have to use shortcodes.

    thomask

    (@thomask)

    same for me. I guess some problems with the latest versions of PHP or WP

    Thread Starter thomask

    (@thomask)

    WP 5.8.1., PHP 8 (some latest)

    The problem probably occurs, when user duplicate a block / one slide and then edits it. I need to watch the user what is he doing, it has never happened to me on the same instalation. But still a good plugin should never fire fatal errors on some post edit action of non-admin user (it was wired on both frontend and admin edit post, so i had to turn off the plugin and then delete the added slide to revert it).

    Thread Starter thomask

    (@thomask)

    and another one after he tried it again
    Fatal error: Uncaught ArgumentCountError: 9 arguments are required, 3 given in /var/www/vhosts/sciencein.cz/httpdocs/wp-content/plugins/gutenslider/src/blocks/gutenslide/block-front.php:280 Stack trace: #0 /var/www/vhosts/sciencein.cz/httpdocs/wp-content/plugins/gutenslider/src/blocks/gutenslide/block-front.php(280): sprintf() #1 /var/www/vhosts/sciencein.cz/httpdocs/wp-content/plugins/gutenberg/lib/compat.php(132): eedee_gutenslide_dynamic_render_callback() #2 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/class-wp-block.php(221): {closure}() #3 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/class-wp-block.php(211): WP_Block->render() #4 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/blocks.php(868): WP_Block->render() #5 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/blocks.php(906): render_block() #6 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/class-wp-hook.php(303): do_blocks() #7 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/plugin.php(189): WP_Hook->apply_filters() #8 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/post-template.php(253): apply_filters() #9 /var/www/vhosts/sciencein.cz/httpdocs/wp-content/themes/generatepress/content-page.php(73): the_content() #10 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/template.php(772): require('...') #11 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/template.php(716): load_template() #12 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/general-template.php(204): locate_template() #13 /var/www/vhosts/sciencein.cz/httpdocs/wp-content/themes/generatepress/inc/theme-functions.php(568): get_template_part() #14 /var/www/vhosts/sciencein.cz/httpdocs/wp-content/themes/generatepress/page.php(34): generate_do_template_part() #15 /var/www/vhosts/sciencein.cz/httpdocs/wp-includes/template-loader.php(106): include('...') #16 /var/www/vhosts/sciencein.cz/httpdocs/wp-blog-header.php(19): require_once('...') #17 /var/www/vhosts/sciencein.cz/httpdocs/index.php(17): require('...') #18 {main} thrown in /var/www/vhosts/sciencein.cz/httpdocs/wp-content/plugins/gutenslider/src/blocks/gutenslide/block-front.php on line 280

    you clearly have many uncatched fatal errors, i will change the slider plugin

    Thread Starter thomask

    (@thomask)

    Dear Dave, i do not care HOW you do it, i can probably imagine how you could do it. What i say is that your methods are probably bugy or not working at all, when 100 % of typical bot visits you declare as human. If i would show even one bot in all the visits, i would say, that it just works sub-optimally, but when 100 % of thousands and thousands of visits are defined as human, than i guess it does not work at all.

    P.S.: wordfence has no javascript on the page if user is not logged in, so I have no clue how you would be able to observe activity on page with javascript.

    Thread Starter thomask

    (@thomask)

    Oh @wfgerald, i see. The problem is, that i use the latest jQuery version (3.4.1), not that wp built-in from several years old ver. 1.2 branch. Interesting, you are the only plugin, that got problems with that.
    P.S: this is my setup if you want to test it with modern jquery

    
    //Remove JQuery migrate
    function remove_jquery_migrate($scripts)
    {
        if (!is_admin() && isset($scripts->registered['jquery'])) {
            $script = $scripts->registered['jquery'];
            
            if ($script->deps) { // Check whether the script has any dependencies
                $script->deps = array_diff($script->deps, array(
                    'jquery-migrate'
                ));
            }
        }
    }
    
    add_action('wp_default_scripts', 'remove_jquery_migrate');
    
    //Making jQuery to load from Google Library
    function replace_jquery() {
    	if (!is_admin()) {
    		// comment out the next two lines to load the local copy of jQuery
    		wp_deregister_script('jquery');
    		wp_register_script('jquery', '//code.jquery.com/jquery-3.4.1.slim.min.js', false, NULL,true);
    		wp_enqueue_script('jquery');                                                                           
            wp_script_add_data( 'jquery', 'integrity', 'sha256-pasqAKBDmFT4eHoN2ndd6lN370kFiGUFyTiUHWhU7k8=');
            wp_script_add_data( 'jquery', 'crossorigin', 'anonymous' );
    	}    
    }
    add_action('init', 'replace_jquery');
    
    Thread Starter thomask

    (@thomask)

    I have found more serious problem with this issue – the title is hidden also from admin area when adding the link to document with a hidden title.

    It is strange and IMO a bug in WordPress, because you are calling !is_admin condition, but as i am googling it, the problem is, that that add link popup uses external json call, e.g. see
    https://stackoverflow.com/questions/39115564/wordpress-hook-filter-to-process-link-inside-a-post/39176559
    I will try to find the filter how to solve it, but maybe you will be quicker as you are clearly more experienced dev. πŸ™‚

    Thread Starter thomask

    (@thomask)

    just for info if someone has the same problem – sometimes it can be “solved” by turning the filter off before the other plugin call and then turn it on back. E.g. for my breadcrumb problem, you can use:

    	    remove_filter( 'the_title', 'editorskit_hide_title', 10, 2 );
    		bcn_display();       
    	    add_filter( 'the_title', 'editorskit_hide_title', 10, 2 );
    Thread Starter thomask

    (@thomask)

    you are right Michael – the problem is in the gutenberg. I can turn it off for this CPT so problem solved for me now πŸ™‚

    Thank you

    Thread Starter thomask

    (@thomask)

    Also from uknown reason metabox of the taxonomy is not in metabox array so I do not know how to delete id via action

    add_action( ‘add_meta_boxes’, function() {

    global $wp_meta_boxes;
    var_dump($wp_meta_boxes);

    }, 999);

Viewing 15 replies - 1 through 15 (of 110 total)