├── .browserslistrc
├── assets
└── src
│ ├── img
│ ├── favicon.ico
│ ├── icon-192.png
│ ├── icon-512.png
│ ├── icon-128x128.png
│ ├── icon-256x256.png
│ ├── screenshot-1.png
│ ├── screenshot-2.png
│ ├── screenshot-3.png
│ ├── screenshot-4.png
│ ├── screenshot-5.png
│ ├── screenshot-6.png
│ ├── banner-1544x500.png
│ ├── banner-772x250.png
│ ├── apple-touch-icon.png
│ ├── screenshot-1-830x447.png
│ └── icon.svg
│ ├── js
│ ├── sw.js
│ ├── koko-analytics-script-test.js
│ ├── dashboard-widget.js
│ ├── query-loop-block.js
│ ├── script.js
│ ├── dashboard.js
│ └── imports
│ │ └── chart.js
│ ├── manifest.json
│ └── github
│ ├── lighthouse_performance.svg
│ └── lighthouse_accessibility.svg
├── code-snippets
├── disable-custom-endpoint.php
├── modify-dashboard-widget
│ ├── img.png
│ └── README.md
├── add-domains-to-referrer-blocklist.php
├── event-snippets
│ ├── track-form-submits.js
│ ├── track-browser-language.js
│ ├── track-gravity-form-submits.js
│ ├── track-outbound-link-clicks.js
│ ├── track-contact-form-7-submits.js
│ ├── track-404-not-found.php
│ ├── track-screen-width.js
│ ├── bebop-theme-track-plays.js
│ ├── track-mailto-or-tel-link-clicks.js
│ ├── track-ninja-forms-submit.js
│ ├── track-time-to-interactive.js
│ ├── README.md
│ ├── track-utm-parameters.js
│ ├── track-utm-parameters-all.js
│ ├── track-utm-parameters.php
│ └── track-utm-parameters-all-combined.js
├── disable-tracking-for-404s.php
├── allow-editor-role.php
├── remove-pageviews-from-admin-bar.php
├── filter-default-option-value.php
├── disable-tracking-on-certain-urls.php
├── filter-pageviews-column-days-setting.php
├── change-chart-colors.php
├── complianz-use-cookie-after-consent.js
├── track-pageview-from-single-page-app.js
├── disable-tracking-for-certain-ip-addresses.php
├── ignore-tracking-for-certain-user-agents.php
├── ignore-some-referrer-traffic-using-regex.php
├── consolidate-referrer-urls.php
├── use-different-custom-endpoint.php
└── README.md
├── .gitignore
├── tests
├── bootstrap.php
├── StatsTest.php
├── PrunerTest.php
├── Admin
│ └── AdminTest.php
├── AggregatorTest.php
├── ScriptLoaderTest.php
├── DashboardWidgetTest.php
├── PluginTest.php
├── EndpointInstallerTest.php
├── BlocklistTest.php
├── benchmarks
│ ├── functions.php
│ ├── join-vs-str-repeat.php
│ ├── read-dir.php
│ ├── return-bool-vs-return-void.php
│ ├── static-method-vs-instance-method.php
│ ├── plugin.php
│ ├── read-line.php
│ ├── run-benchmark.php
│ └── parse-url.php
├── MigrationsTest.php
├── FmtTest.php
├── RestTest.php
├── mocks.php
├── FunctionsTest.php
└── Normalizers
│ └── NormalizerTest.php
├── bin
├── update-referrer-blocklist
├── update-external-strings
├── check-php-syntax
└── create-package
├── eslint.config.mjs
├── data
└── .htaccess
├── migrations
├── 2.0.20-drop-temporary-post-stats-table.php
├── 1.0.4-fix-referrer-id-column.php
├── 1.8.1-verify-optimized-endpoint.php
├── 1.8.5-update-optimized-endpoint.php
├── 2.0.13-fix-post-id-column-type.php
├── 1.7.0-protect-uploads-dir.php
├── 1.3.12-delete-invalid-referrers.php
├── 1.9.992-maybe-migrate-post-stats.php
├── 2.0.12-fix-incorrect-post-paths.php
├── 1.0.6-ensure-referrer-urls-table-exists.php
├── 1.9.993-maybe-migrate-referrer-stats.php
├── 1.0.1-change-table-charsets.php
├── 2.0.11-set-paths-table-collation.php
├── 1.0.8-fix-referrer-id-column-again.php
├── 1.8.0-migrate-to-new-tracking-method-setting.php
├── 1.6.3-schedule-aggregate-event.php
├── 1.1.1-create-dates-table.php
├── 1.9.991-prepare-post-stats-table.php
├── 1.0.2-change-column-types-add-cap-to-administrator.php
└── 1.0.0-initial-schema.php
├── src
├── collect-functions.php
├── class-aggregator.php
├── class-pageview-aggregator.php
├── Normalizers
│ ├── Normalizer.php
│ ├── Path.php
│ └── Referrer.php
├── Dashboard_Standalone.php
├── Resources
│ ├── views
│ │ ├── nav.php
│ │ ├── settings
│ │ │ ├── events.php
│ │ │ ├── email-reports.php
│ │ │ ├── performance.php
│ │ │ ├── dashboard.php
│ │ │ ├── help.php
│ │ │ ├── plausible_importer.php
│ │ │ ├── data.php
│ │ │ ├── tracking.php
│ │ │ └── jetpack_importer.php
│ │ ├── standalone.php
│ │ ├── dashboard-widget.php
│ │ └── settings-page.php
│ ├── backwards-compat.php
│ └── functions
│ │ └── global.php
├── Admin
│ ├── Bar.php
│ ├── Data_Reset.php
│ ├── Data_Import.php
│ └── Pages.php
├── Fmt.php
├── Blocklist.php
├── Actions.php
├── Path_Repository.php
├── Referrer_Repository.php
├── Plugin.php
├── Router.php
├── Command.php
├── Query_Loop_Block.php
├── Dashboard_Widget.php
├── Shortcodes
│ ├── Shortcode_Most_Viewed_Posts.php
│ └── Shortcode_Site_Counter.php
├── Fingerprinter.php
├── Migrations.php
├── Import
│ ├── Importer.php
│ └── Plausible_Importer.php
├── Aggregator.php
├── Notice_Pro.php
├── Pruner.php
├── Script_Loader.php
└── Chart_View.php
├── .editorconfig
├── SECURITY.md
├── autoload.php
├── .github
└── workflows
│ ├── check-php-syntax.yml
│ └── build-test-lint.yml
├── phpcs.xml
├── composer.json
├── phpunit.xml.dist
├── webpack.config.js
├── uninstall.php
├── package.json
└── README.md
/.browserslistrc:
--------------------------------------------------------------------------------
1 | # Browsers that we support
2 | defaults
3 |
--------------------------------------------------------------------------------
/assets/src/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ibericode/koko-analytics/main/assets/src/img/favicon.ico
--------------------------------------------------------------------------------
/code-snippets/disable-custom-endpoint.php:
--------------------------------------------------------------------------------
1 | {
2 | event.respondWith(
3 | fetch(event.request)
4 | );
5 | });
6 |
--------------------------------------------------------------------------------
/code-snippets/modify-dashboard-widget/img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ibericode/koko-analytics/main/code-snippets/modify-dashboard-widget/img.png
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 |
3 | Order Deny,Allow
4 | Deny from all
5 |
6 |
7 | # Apache 2.4
8 |
9 | Require all denied
10 |
11 |
--------------------------------------------------------------------------------
/code-snippets/add-domains-to-referrer-blocklist.php:
--------------------------------------------------------------------------------
1 | query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_post_stats_old");
9 |
--------------------------------------------------------------------------------
/code-snippets/disable-tracking-for-404s.php:
--------------------------------------------------------------------------------
1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls MODIFY id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT");
9 |
--------------------------------------------------------------------------------
/migrations/1.8.1-verify-optimized-endpoint.php:
--------------------------------------------------------------------------------
1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY COLUMN post_id INT UNSIGNED NOT NULL DEFAULT 0");
9 |
--------------------------------------------------------------------------------
/code-snippets/allow-editor-role.php:
--------------------------------------------------------------------------------
1 | has_cap('view_koko_analytics')) {
7 | $role->add_cap('view_koko_analytics');
8 | }
9 | });
10 |
--------------------------------------------------------------------------------
/code-snippets/remove-pageviews-from-admin-bar.php:
--------------------------------------------------------------------------------
1 | src/Resources/external-strings.php
6 | ./vendor/bin/phpcbf src/Resources/external-strings.php || true
7 |
8 | git add src/Resources/external-strings.php || true
9 | git commit -m "Update external strings from Koko Analytics Pro"
10 |
--------------------------------------------------------------------------------
/migrations/1.7.0-protect-uploads-dir.php:
--------------------------------------------------------------------------------
1 | query($wpdb->prepare("DELETE s, u FROM {$wpdb->prefix}koko_analytics_referrer_stats s LEFT JOIN {$wpdb->prefix}koko_analytics_referrer_urls u ON s.id = u.id WHERE u.url LIKE %s;", [$site_url . '%']));
11 |
--------------------------------------------------------------------------------
/src/Dashboard_Standalone.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tests/ScriptLoaderTest.php:
--------------------------------------------------------------------------------
1 | get_var("SELECT COUNT(*) FROM {$wpdb->prefix}koko_analytics_post_stats");
11 | if ($count && $count < 25000 && method_exists(Actions::class, 'migrate_post_stats_to_v2')) {
12 | Actions::migrate_post_stats_to_v2();
13 | }
14 |
--------------------------------------------------------------------------------
/migrations/2.0.12-fix-incorrect-post-paths.php:
--------------------------------------------------------------------------------
1 | get_var("SELECT COUNT(*) FROM {$wpdb->prefix}koko_analytics_post_stats");
11 | if ($count && $count < 25000 && method_exists(Actions::class, 'fix_post_paths_after_v2')) {
12 | Actions::fix_post_paths_after_v2();
13 | }
14 |
--------------------------------------------------------------------------------
/migrations/1.0.6-ensure-referrer-urls-table-exists.php:
--------------------------------------------------------------------------------
1 | query(
9 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_referrer_urls (
10 | id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
11 | url VARCHAR(255) NOT NULL,
12 | UNIQUE INDEX (url)
13 | ) ENGINE=INNODB CHARACTER SET=ascii"
14 | );
15 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; top-most EditorConfig file
2 | root = true
3 |
4 | ; Unix-style newlines
5 | [*]
6 | charset = utf-8
7 | end_of_line = LF
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.{php,html}]
12 | indent_style = space
13 | indent_size = 4
14 |
15 | [*.{js,css}]
16 | indent_style = space
17 | indent_size = 2
18 |
19 | [*.md]
20 | max_line_length = 80
21 |
22 | [COMMIT_EDITMSG]
23 | max_line_length = 0
--------------------------------------------------------------------------------
/tests/DashboardWidgetTest.php:
--------------------------------------------------------------------------------
1 | -1) {
8 | return;
9 | }
10 |
11 | window.koko_analytics.trackEvent('Outbound click', href);
12 | });
13 |
--------------------------------------------------------------------------------
/src/Resources/backwards-compat.php:
--------------------------------------------------------------------------------
1 |
9 |
19 | Integrations > Script Center.
8 |
--------------------------------------------------------------------------------
/code-snippets/track-pageview-from-single-page-app.js:
--------------------------------------------------------------------------------
1 | let postId = -1 // general site visit
2 |
3 | // if this is a visit to a specific page or post, we need to supply the post ID to trackPageview
4 | // so we extract if from the
element
5 | const matches = document.body.className.match(/(postid-|page-id-)(\d+)/)
6 | if (matches && matches.length === 3) {
7 | postId = matches.pop()
8 | }
9 |
10 | window.koko_analytics.trackPageview(postId)
11 |
--------------------------------------------------------------------------------
/migrations/1.9.993-maybe-migrate-referrer-stats.php:
--------------------------------------------------------------------------------
1 | get_var("SELECT COUNT(*) FROM {$wpdb->prefix}koko_analytics_referrer_stats");
11 | if ($count && $count < 25000 && method_exists(Actions::class, 'migrate_referrer_stats_to_v2')) {
12 | Actions::migrate_referrer_stats_to_v2();
13 | }
14 |
--------------------------------------------------------------------------------
/tests/EndpointInstallerTest.php:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 | Custom events
6 | Custom events is a feature from Koko Analytics Pro that allows you to track any type of event that occurs on your site, like outbound link clicks or form submissions.
7 | Upgrade to Koko Analytics Pro
8 |
9 |
10 |
--------------------------------------------------------------------------------
/tests/BlocklistTest.php:
--------------------------------------------------------------------------------
1 | contains(''));
16 | self::assertFalse($b->contains(''));
17 | self::assertTrue($b->contains('1xslot.site'));
18 | self::assertFalse($b->contains('kokoanalytics.com'));
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/assets/src/js/query-loop-block.js:
--------------------------------------------------------------------------------
1 | const MY_VARIATION_NAME = 'koko-analytics/most-viewed-pages';
2 |
3 | window.wp.blocks.registerBlockVariation( 'core/query', {
4 | apiVersion: 3,
5 | name: MY_VARIATION_NAME,
6 | title: 'Most Viewed Post Type',
7 | description: 'Displays a list of your most viewed posts, pages or other post types.',
8 | isActive: [ 'namespace' ],
9 | icon: '',
10 | attributes: {
11 | namespace: MY_VARIATION_NAME,
12 | },
13 | scope: [ 'inserter' ],
14 | allowedControls: [ 'postType'],
15 | }
16 | );
17 |
--------------------------------------------------------------------------------
/migrations/1.0.1-change-table-charsets.php:
--------------------------------------------------------------------------------
1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_site_stats CONVERT TO CHARACTER SET ascii;");
9 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats CONVERT TO CHARACTER SET ascii;");
10 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats CONVERT TO CHARACTER SET ascii;");
11 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls CONVERT TO CHARACTER SET ascii;");
12 |
--------------------------------------------------------------------------------
/code-snippets/event-snippets/track-screen-width.js:
--------------------------------------------------------------------------------
1 | window.addEventListener('load', function() {
2 | function bucket(n) {
3 | if (n < 500) {
4 | return '0 - 500';
5 | } else if (n < 1000) {
6 | return '500 - 1000';
7 | } else if (n < 1500) {
8 | return '1000 - 1500';
9 | } else if (n < 2000) {
10 | return '1500 - 2000';
11 | } else {
12 | return '2000+';
13 | }
14 | }
15 |
16 | window.koko_analytics.trackEvent('Screen width', bucket(screen.width));
17 | });
18 |
--------------------------------------------------------------------------------
/migrations/2.0.11-set-paths-table-collation.php:
--------------------------------------------------------------------------------
1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_paths CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;");
12 |
--------------------------------------------------------------------------------
/code-snippets/ignore-some-referrer-traffic-using-regex.php:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/migrations/1.0.8-fix-referrer-id-column-again.php:
--------------------------------------------------------------------------------
1 | get_var("SELECT MAX(id) FROM {$wpdb->prefix}koko_analytics_referrer_urls");
11 | $max_id++;
12 | $query = $wpdb->prepare("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls AUTO_INCREMENT = %d, MODIFY id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT", $max_id);
13 | $wpdb->query($query);
14 |
--------------------------------------------------------------------------------
/migrations/1.8.0-migrate-to-new-tracking-method-setting.php:
--------------------------------------------------------------------------------
1 | 60, // 60 seconds
10 | 'display' => esc_html__('Every minute', 'koko-analytics'),
11 | ];
12 | return $schedules;
13 | }, 10, 1);
14 |
15 | // schedule event
16 | wp_schedule_event(time() + 60, 'koko_analytics_stats_aggregate_interval', 'koko_analytics_aggregate_stats');
17 | }
18 |
--------------------------------------------------------------------------------
/autoload.php:
--------------------------------------------------------------------------------
1 |
2 |
3 | rules
4 |
5 | code-snippets/
6 | src/
7 | tests/
8 | migrations/
9 | koko-analytics.php
10 | uninstall.php
11 | autoload.php
12 | *\.(html|css|js)
13 | tests/benchmarks/*
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/tests/benchmarks/return-bool-vs-return-void.php:
--------------------------------------------------------------------------------
1 | add_node(
22 | [
23 | 'parent' => 'site-name',
24 | 'id' => 'koko-analytics',
25 | 'title' => esc_html__('Analytics', 'koko-analytics'),
26 | 'href' => admin_url('/index.php?page=koko-analytics'),
27 | ]
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/email-reports.php:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 | = esc_html__('Email reports', 'koko-analytics') ?>
11 |
12 | Email reports is a feature from Koko Analytics Pro that allows you to configure periodic email reports, delivering a summary of your most important metrics to your email inbox.
13 | Upgrade to Koko Analytics Pro
14 |
15 |
--------------------------------------------------------------------------------
/assets/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Koko Analytics",
3 | "short_name": "Koko Analytics",
4 | "description": "Analytics dashboard for your WordPress site. Powered by Koko Analytics.",
5 | "icons": [
6 | {
7 | "src": "img/icon.svg",
8 | "sizes": "48x48 72x72 96x96 128x128 256x256 512x512",
9 | "type": "image/svg+xml",
10 | "purpose": "any maskable"
11 | },
12 | {
13 | "src": "img/icon-192.png",
14 | "type": "image/png",
15 | "sizes": "192x192",
16 | "purpose": "maskable"
17 | },
18 | {
19 | "src": "img/icon-512.png",
20 | "type": "image/png",
21 | "sizes": "512x512",
22 | "purpose": "maskable"
23 | }
24 | ],
25 | "display": "standalone",
26 | "start_url": "/?koko-analytics-dashboard",
27 | "theme_color": "#FFF",
28 | "background_color": "#FFF"
29 | }
30 |
--------------------------------------------------------------------------------
/code-snippets/event-snippets/track-utm-parameters-all.js:
--------------------------------------------------------------------------------
1 | // This code snippets monitors the URL for UTM parameters like "utm_source=WordPress.org" and sends it to a Koko Analytics custom event
2 | // In order for this to work, you need to have the custom event created in your Koko Analytics dashboard settings.
3 |
4 | window.addEventListener('load', function() {
5 | let map = {
6 | 'utm_source': 'UTM Source',
7 | 'utm_medium': 'UTM Medium',
8 | 'utm_campaign': 'UTM Campaign',
9 | };
10 |
11 | let queryParams = new URLSearchParams(window.location.search);
12 | let hashParams = new URLSearchParams(window.location.hash.substring(1));
13 | for (let [p, eventName] of Object.entries(map)) {
14 | let value = queryParams.get(p) ?? hashParams.get(p);
15 | if (value) {
16 | window.koko_analytics.trackEvent(eventName, value);
17 | }
18 | }
19 | });
20 |
--------------------------------------------------------------------------------
/code-snippets/use-different-custom-endpoint.php:
--------------------------------------------------------------------------------
1 | dir)) {
17 | mkdir($this->dir, 0700, true);
18 | }
19 | }
20 |
21 | public function testCanInstantiate()
22 | {
23 | $instance = new Migrations('1.0', '1.1', $this->dir);
24 | $this->assertInstanceOf(Migrations::class, $instance);
25 | }
26 |
27 | public function tearDown(): void
28 | {
29 | array_map('unlink', glob($this->dir . '/*.php'));
30 | if (file_exists($this->dir)) {
31 | rmdir($this->dir);
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/code-snippets/event-snippets/track-utm-parameters.php:
--------------------------------------------------------------------------------
1 | 7.4",
21 | "ext-json": "*"
22 | },
23 | "require-dev": {
24 | "phpunit/phpunit": "^9.6",
25 | "squizlabs/php_codesniffer": "^3.11"
26 | },
27 | "scripts": {
28 | "test": "vendor/bin/phpunit",
29 | "check-syntax": "bin/check-php-syntax",
30 | "lint": "vendor/bin/phpcs -n -s",
31 | "fmt": "vendor/bin/phpcbf"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/code-snippets/event-snippets/track-utm-parameters-all-combined.js:
--------------------------------------------------------------------------------
1 | // Event name: UTM Parameters
2 | // This snippet tracks any UTM parameter into a single "UTM Parameters" event using the following format: Source / Medium / Campaign
3 | window.addEventListener('load', function() {
4 | let queryParams = new URLSearchParams(window.location.search);
5 | let hashParams = new URLSearchParams(window.location.hash.substring(1));
6 |
7 | let source = queryParams.get('utm_source') ?? hashParams.get('utm_source');
8 | let medium = queryParams.get('utm_medium') ?? hashParams.get('utm_medium');
9 | let campaign = queryParams.get('utm_campaign') ?? hashParams.get('utm_campaign');
10 |
11 | if (source || medium || campaign) {
12 | let eventName = 'UTM Parameters';
13 | let eventValue = [source, medium, campaign].filter(v => !!v).join(' / ')
14 |
15 | window.koko_analytics.trackEvent(eventName, eventValue);
16 | }
17 | });
18 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | tests
19 |
20 |
21 |
22 |
23 |
24 | src
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/tests/benchmarks/static-method-vs-instance-method.php:
--------------------------------------------------------------------------------
1 | method();
25 | }, $iterations);
26 | printf("new instance method \t%.2f ns\t(%.2f per it)\n", $time, $time / $iterations);
27 |
28 | // existing instance
29 | $instance = new A();
30 | $time = bench(function () use ($instance) {
31 | $instance->method();
32 | }, $iterations);
33 | printf("instance method \t%.2f ns\t(%.2f per it)\n", $time, $time / $iterations);
34 |
35 | // static method
36 | $time = bench(function () {
37 | B::method();
38 | }, $iterations);
39 | printf("static method\t\t%.2f ns\t(%.2f per it)\n", $time, $time / $iterations);
40 |
--------------------------------------------------------------------------------
/src/Fmt.php:
--------------------------------------------------------------------------------
1 | 0 ? '+' : '';
14 | $formatted = \number_format_i18n($pct * 100, 0);
15 | return $prefix . $formatted . '%';
16 | }
17 |
18 | public static function referrer_url_label(string $url): string
19 | {
20 | // if link starts with android-app://, turn that prefix into something more human readable
21 | if (\strpos($url, 'android-app://') === 0) {
22 | return \str_replace('android-app://', 'Android app: ', $url);
23 | }
24 |
25 | // strip protocol and www. prefix
26 | $url = (string) \preg_replace('/^https?:\/\/(?:www\.)?/', '', $url);
27 |
28 | // trim trailing slash
29 | $url = \rtrim($url, '/');
30 |
31 | return apply_filters('koko_analytics_referrer_url_label', $url);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const CopyPlugin = require("copy-webpack-plugin");
3 |
4 | module.exports = {
5 | entry: {
6 | dashboard: './assets/src/js/dashboard.js',
7 | 'dashboard-widget': './assets/src/js/dashboard-widget.js',
8 | script: './assets/src/js/script.js',
9 | sw: './assets/src/js/sw.js',
10 | 'koko-analytics-script-test': './assets/src/js/koko-analytics-script-test.js',
11 | 'query-loop-block': './assets/src/js/query-loop-block.js'
12 | },
13 | output: {
14 | filename: '[name].js',
15 | path: path.resolve(__dirname, 'assets/dist/js')
16 | },
17 | plugins: [
18 | new CopyPlugin({
19 | patterns: [
20 | { from: './assets/src/img', to: path.resolve(__dirname, './assets/dist/img') },
21 | { from: './assets/src/css', to: path.resolve(__dirname, './assets/dist/css') },
22 | { from: './assets/src/manifest.json', to: path.resolve(__dirname, './assets/dist/manifest.json') }
23 | ],
24 | }),
25 | ],
26 | }
27 |
--------------------------------------------------------------------------------
/bin/check-php-syntax:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | getPathname(), './vendor/') === 0
17 | || strpos($file->getPathname(), './node_modules/') === 0
18 | || false == $file->isFile()
19 | || $file->getExtension() !== 'php'
20 | ) {
21 | continue;
22 | }
23 |
24 | $exit_code = 0;
25 | $output = [];
26 | exec("php --define error_reporting=-1 -l {$file->getPathname()}", $output, $exit_code);
27 | $output = join("\n", $output);
28 | echo $output . "\n";
29 |
30 | if ($exit_code || strpos($output, 'Deprecated') !== false) {
31 | $global_exit_code = 1;
32 | }
33 | }
34 |
35 | exit($global_exit_code);
36 |
--------------------------------------------------------------------------------
/assets/src/js/script.js:
--------------------------------------------------------------------------------
1 | // Map variables to global identifiers so that minifier can mangle them to even shorter names
2 | var win = window;
3 | var ka = "koko_analytics";
4 |
5 | function request(data) {
6 | data['m'] = win[ka].use_cookie ? 'c' : win[ka].method[0];
7 | navigator.sendBeacon(win[ka].url, new URLSearchParams(data));
8 | }
9 |
10 | function trackPageview() {
11 | if (
12 | // do not track if this is a prerender request
13 | (document.visibilityState == 'prerender') ||
14 |
15 | // do not track if user agent looks like a bot
16 | ((/bot|crawl|spider|seo|lighthouse|facebookexternalhit|preview/i).test(navigator.userAgent))
17 | ) {
18 | return;
19 | }
20 |
21 | // don't store referrer if from same-site
22 | var referrer = document.referrer.indexOf(win[ka].site_url) == 0 ? '' : document.referrer;
23 | request({ pa: win[ka].path, po: win[ka].post_id, r: referrer })
24 | }
25 |
26 | win[ka].request = request;
27 | win[ka].trackPageview = trackPageview;
28 |
29 | win.addEventListener('load', function() { win[ka].trackPageview() });
30 |
--------------------------------------------------------------------------------
/tests/benchmarks/plugin.php:
--------------------------------------------------------------------------------
1 | > 10;
31 |
32 | header("Content-Type: application/json");
33 | echo json_encode(['memory' => $memory_used, 'time' => $time]);
34 |
--------------------------------------------------------------------------------
/uninstall.php:
--------------------------------------------------------------------------------
1 | query("DROP TABLE {$wpdb->prefix}koko_analytics_site_stats;");
27 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_post_stats;");
28 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_paths;");
29 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_referrer_stats;");
30 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_referrer_urls;");
31 |
--------------------------------------------------------------------------------
/code-snippets/README.md:
--------------------------------------------------------------------------------
1 | # Code snippets for Koko Analytics
2 |
3 | This directory contains a collection of code snippets to modify or extend the default behavior of the [Koko Analytics plugin](https://www.kokoanalytics.com/).
4 |
5 | You can copy these snippets into a custom plugin or in your active theme its `functions.php` file.
6 | Our recommendation is to create a file called `/wp-content/plugins/koko-analytics-modifications.php` with the following file contents:
7 |
8 | Please read [modifying Koko Analytics using filter hooks](https://www.kokoanalytics.com/kb/modifying-koko-analytics-using-filter-hooks/) if you're unusure how to use any of these snippets.
9 |
10 | ```php
11 | query(
9 | "DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_dates"
10 | );
11 | $wpdb->query(
12 | "CREATE TABLE {$wpdb->prefix}koko_analytics_dates (
13 | date DATE PRIMARY KEY NOT NULL
14 | ) ENGINE=INNODB CHARACTER SET=ascii"
15 | );
16 |
17 | $date = new \DateTime('2000-01-01');
18 | $end = new \DateTime('2100-01-01');
19 | $values = [];
20 | while ($date < $end) {
21 | $values[] = $date->format('Y-m-d');
22 | $date->modify('+1 day');
23 |
24 | if (count($values) === 365) {
25 | $placeholders = rtrim(str_repeat('(%s),', count($values)), ',');
26 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_dates(date) VALUES {$placeholders}", $values));
27 | $values = [];
28 | }
29 | }
30 |
31 | $placeholders = rtrim(str_repeat('(%s),', count($values)), ',');
32 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_dates(date) VALUES {$placeholders}", $values));
33 |
--------------------------------------------------------------------------------
/tests/FmtTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($fmt->percent(0), '');
16 | $this->assertEquals($fmt->percent(0.00), '');
17 | $this->assertEquals($fmt->percent(1.00), '+100%');
18 | $this->assertEquals($fmt->percent(-1.00), '-100%');
19 | $this->assertEquals($fmt->percent(0.55), '+55%');
20 | $this->assertEquals($fmt->percent(-0.55), '-55%');
21 | }
22 |
23 | public function testGetReferrerUrlLabel(): void
24 | {
25 | $fmt = new Fmt();
26 |
27 | self::assertEquals('', $fmt->referrer_url_label(''));
28 | self::assertEquals('kokoanalytics.com', $fmt->referrer_url_label('https://www.kokoanalytics.com/'));
29 | self::assertEquals('kokoanalytics.com/about', $fmt->referrer_url_label('https://www.kokoanalytics.com/about'));
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "koko-analytics",
3 | "description": "Privacy-friendly analytics for your WordPress site",
4 | "scripts": {
5 | "build": "webpack --mode=production",
6 | "watch": "webpack --mode=development --watch",
7 | "download-referrer-blocklist": "curl https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt -k -o data/referrer-blocklist",
8 | "create-pot": "wp i18n make-pot .",
9 | "lint": "eslint assets/src/js/*.js"
10 | },
11 | "private": true,
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/ibericode/koko-analytics.git"
15 | },
16 | "keywords": [
17 | "wordpress"
18 | ],
19 | "author": "Danny van Kooten",
20 | "license": "GPL-3.0-or-later",
21 | "bugs": {
22 | "url": "https://github.com/ibericode/koko-analytics/issues"
23 | },
24 | "homepage": "https://github.com/ibericode/koko-analytics#readme",
25 | "devDependencies": {
26 | "@eslint/js": "^9.31.0",
27 | "copy-webpack-plugin": "^13.0.0",
28 | "eslint": "^9.31.0",
29 | "globals": "^16.3.0",
30 | "webpack": "^5.100.2",
31 | "webpack-cli": "^6.0.1"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Blocklist.php:
--------------------------------------------------------------------------------
1 | getFilename();
17 | if (!is_file($filename)) {
18 | return [];
19 | }
20 |
21 | return \file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
22 | }
23 |
24 | public function contains(string $url): bool
25 | {
26 | if ($url === '') {
27 | return false;
28 | }
29 |
30 | static $list;
31 | if ($list === null) {
32 | $list = $this->read();
33 | }
34 |
35 | foreach ($list as $domain) {
36 | $domain = trim($domain);
37 |
38 | if ($domain === '') {
39 | continue;
40 | }
41 |
42 | if (str_contains($url, $domain)) {
43 | return true;
44 | }
45 | }
46 |
47 | return false;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Actions.php:
--------------------------------------------------------------------------------
1 | lighthouse performance: 100% lighthouse performance lighthouse performance 100% 100%
--------------------------------------------------------------------------------
/assets/src/github/lighthouse_accessibility.svg:
--------------------------------------------------------------------------------
1 | lighthouse accessibility: 80% lighthouse accessibility lighthouse accessibility 80% 80%
--------------------------------------------------------------------------------
/tests/RestTest.php:
--------------------------------------------------------------------------------
1 | validate_date_param('2000-01-01', null, null));
22 | self::assertFalse($rest->validate_date_param('foobar', null, null));
23 | }
24 |
25 | public function test_sanitize_bool_param()
26 | {
27 | $rest = new Rest();
28 | self::assertTrue($rest->sanitize_bool_param('true', null, null));
29 | self::assertTrue($rest->sanitize_bool_param('yes', null, null));
30 | self::assertTrue($rest->sanitize_bool_param('1', null, null));
31 | self::assertFalse($rest->sanitize_bool_param('0', null, null));
32 | self::assertFalse($rest->sanitize_bool_param('no', null, null));
33 | self::assertFalse($rest->sanitize_bool_param('false', null, null));
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/migrations/1.9.991-prepare-post-stats-table.php:
--------------------------------------------------------------------------------
1 | hide_errors();
9 |
10 | $wpdb->query(
11 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_paths (
12 | id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
13 | path VARCHAR(2000) NOT NULL,
14 | INDEX (path(191))
15 | ) ENGINE=INNODB CHARACTER SET=utf8mb4"
16 | );
17 |
18 | // prepare columns
19 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats CHANGE COLUMN id post_id INT UNSIGNED");
20 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats ADD COLUMN path_id MEDIUMINT UNSIGNED");
21 |
22 | $results = $wpdb->get_var("SELECT COUNT(DISTINCT(post_id)) FROM {$wpdb->prefix}koko_analytics_post_stats WHERE post_id IS NOT NULL AND path_id IS NULL");
23 |
24 | if (!$results) {
25 | // make new path_id column not-nullable
26 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY COLUMN path_id MEDIUMINT UNSIGNED NOT NULL");
27 |
28 | // change primary key to be on date and path_id column
29 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats DROP PRIMARY KEY, ADD PRIMARY KEY(date, path_id)");
30 | }
31 |
--------------------------------------------------------------------------------
/src/Path_Repository.php:
--------------------------------------------------------------------------------
1 | get_results($wpdb->prepare("SELECT id, path FROM {$wpdb->prefix}koko_analytics_paths p WHERE p.path IN({$placeholders})", $paths));
16 |
17 | // fill map with path ID's from database
18 | foreach ($results as $r) {
19 | $map[$r->path] = $r->id;
20 | }
21 |
22 | // get all entries without an ID
23 | $new_values = array_keys($map, 0);
24 |
25 | if (count($new_values) > 0) {
26 | $placeholders = rtrim(str_repeat('(%s),', count($new_values)), ',');
27 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_paths(path) VALUES {$placeholders}", $new_values));
28 | $last_insert_id = $wpdb->insert_id;
29 |
30 | foreach ($new_values as $key) {
31 | $map[$key] = $last_insert_id++;
32 | }
33 | }
34 |
35 | return $map;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/migrations/1.0.2-change-column-types-add-cap-to-administrator.php:
--------------------------------------------------------------------------------
1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_site_stats MODIFY visitors MEDIUMINT UNSIGNED NOT NULL");
9 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_site_stats MODIFY pageviews MEDIUMINT UNSIGNED NOT NULL");
10 |
11 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY visitors MEDIUMINT UNSIGNED NOT NULL");
12 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY pageviews MEDIUMINT UNSIGNED NOT NULL");
13 |
14 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats MODIFY id MEDIUMINT UNSIGNED NOT NULL");
15 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats MODIFY visitors MEDIUMINT UNSIGNED NOT NULL");
16 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats MODIFY pageviews MEDIUMINT UNSIGNED NOT NULL");
17 |
18 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls MODIFY id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT");
19 |
20 | $role = get_role('administrator');
21 | if ($role) {
22 | $role->add_cap('view_koko_analytics');
23 | $role->add_cap('manage_koko_analytics');
24 | }
25 |
--------------------------------------------------------------------------------
/src/Referrer_Repository.php:
--------------------------------------------------------------------------------
1 | get_results($wpdb->prepare("SELECT id, url FROM {$wpdb->prefix}koko_analytics_referrer_urls r WHERE r.url IN({$placeholders})", $values));
16 |
17 | // fill map with normalized ID's from database
18 | foreach ($results as $r) {
19 | $map[$r->url] = $r->id;
20 | }
21 |
22 | // get all entries without an ID
23 | $new_values = array_keys($map, 0);
24 |
25 | if (count($new_values) > 0) {
26 | $placeholders = rtrim(str_repeat('(%s),', count($new_values)), ',');
27 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_referrer_urls(url) VALUES {$placeholders}", $new_values));
28 | $last_insert_id = $wpdb->insert_id;
29 |
30 | foreach ($new_values as $key) {
31 | $map[$key] = $last_insert_id++;
32 | }
33 | }
34 |
35 | return $map;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Plugin.php:
--------------------------------------------------------------------------------
1 | add_cap('view_koko_analytics');
19 | $role->add_cap('manage_koko_analytics');
20 | }
21 | }
22 |
23 | public static function create_and_protect_uploads_dir(): void
24 | {
25 | $filename = get_buffer_filename();
26 | $directory = \dirname($filename);
27 | if (! \is_dir($directory)) {
28 | \mkdir($directory, 0755, true);
29 | }
30 |
31 | // create empty index.html to prevent directory listing
32 | file_put_contents("$directory/index.html", '');
33 |
34 | // create .htaccess in case we're using apache
35 | $lines = [
36 | '',
37 | 'Order deny,allow',
38 | 'Deny from all',
39 | ' ',
40 | '',
41 | 'Require all denied',
42 | ' ',
43 | '',
44 | ];
45 | file_put_contents("$directory/.htaccess", join(PHP_EOL, $lines));
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/migrations/1.0.0-initial-schema.php:
--------------------------------------------------------------------------------
1 | query(
9 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_site_stats (
10 | date DATE PRIMARY KEY NOT NULL,
11 | visitors MEDIUMINT UNSIGNED NOT NULL,
12 | pageviews MEDIUMINT UNSIGNED NOT NULL
13 | ) ENGINE=INNODB CHARACTER SET=ascii"
14 | );
15 |
16 | $wpdb->query(
17 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_post_stats (
18 | date DATE NOT NULL,
19 | id BIGINT(20) UNSIGNED NOT NULL,
20 | visitors MEDIUMINT UNSIGNED NOT NULL,
21 | pageviews MEDIUMINT UNSIGNED NOT NULL,
22 | PRIMARY KEY (date, id)
23 | ) ENGINE=INNODB CHARACTER SET=ascii"
24 | );
25 |
26 | $wpdb->query(
27 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_referrer_stats (
28 | date DATE NOT NULL,
29 | id MEDIUMINT UNSIGNED NOT NULL,
30 | visitors MEDIUMINT UNSIGNED NOT NULL,
31 | pageviews MEDIUMINT UNSIGNED NOT NULL,
32 | PRIMARY KEY (date, id)
33 | ) ENGINE=INNODB CHARACTER SET=ascii"
34 | );
35 |
36 | $wpdb->query(
37 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_referrer_urls (
38 | id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
39 | url VARCHAR(255) NOT NULL,
40 | UNIQUE INDEX (url)
41 | ) ENGINE=INNODB CHARACTER SET=ascii"
42 | );
43 |
--------------------------------------------------------------------------------
/.github/workflows/build-test-lint.yml:
--------------------------------------------------------------------------------
1 | name: Build, Test and Lint
2 |
3 | on: [ push, pull_request ]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v3
10 |
11 | - name: Setup PHP
12 | id: setup-php
13 | uses: shivammathur/setup-php@v2
14 | with:
15 | php-version: 'highest'
16 |
17 | # Configure Composer to use GitHub action cache
18 | - name: Get Composer Cache Directory
19 | id: composer-cache
20 | run: |
21 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
22 | - uses: actions/cache@v4
23 | with:
24 | path: ${{ steps.composer-cache.outputs.dir }}
25 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
26 | restore-keys: |
27 | ${{ runner.os }}-composer-
28 |
29 | - name: Validate composer.json and composer.lock
30 | run: composer validate
31 |
32 | - name: Install dependencies
33 | run: composer install --prefer-dist --no-progress
34 |
35 | - name: Run PHPUnit
36 | run: composer run-script test
37 |
38 | - name: Run PHPCS
39 | run: composer run-script lint
40 |
41 | - name: Install NPM dependencies
42 | run: npm ci
43 |
44 | - name: Run lint
45 | run: npm run lint
46 |
47 | - name: Run benchmark
48 | run: php tests/benchmarks/run-benchmark.php
49 |
50 |
--------------------------------------------------------------------------------
/tests/benchmarks/read-line.php:
--------------------------------------------------------------------------------
1 | false]);
36 | assert($read == $data);
37 | fseek($fh, 0);
38 | });
39 | printf("unserialize:\t %6.2f\n", $time);
40 |
41 | // prepare json_encode file
42 | ftruncate($fh, 0);
43 | fputs($fh, json_encode($data) . PHP_EOL);
44 | fseek($fh, 0);
45 | $time = bench(function () use ($fh, $data) {
46 | $read = json_decode(trim(fgets($fh)));
47 | assert($read == $data);
48 | fseek($fh, 0);
49 | });
50 | printf("json_decode:\t %6.2f\n", $time);
51 |
52 | fclose($fh);
53 |
--------------------------------------------------------------------------------
/src/Router.php:
--------------------------------------------------------------------------------
1 | null], home_url());
22 | break;
23 |
24 | default:
25 | throw new InvalidArgumentException('No such route: ' . $name);
26 | break;
27 | }
28 | }
29 |
30 | public static function is(string $name): bool
31 | {
32 | global $pagenow;
33 |
34 | switch ($name) {
35 | case 'dashboard-embedded':
36 | return $pagenow === 'index.php' && ($_GET['page'] ?? '') === 'koko-analytics' && ! isset($_GET['tab']);
37 | break;
38 |
39 | case 'dashboard-standalone':
40 | return isset($_GET['koko-analytics-dashboard']) || str_contains($_SERVER['REQUEST_URI'] ?? '', '/koko-analytics-dashboard/');
41 | break;
42 |
43 | default:
44 | break;
45 | }
46 |
47 | return false;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Admin/Data_Reset.php:
--------------------------------------------------------------------------------
1 | query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_site_stats;");
25 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_post_stats;");
26 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_paths;");
27 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_referrer_stats;");
28 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_referrer_urls;");
29 | delete_option('koko_analytics_realtime_pageview_count');
30 |
31 | // delete version option so that migrations re-create all database tables on next page load
32 | delete_option('koko_analytics_version');
33 |
34 | // redirect with success message
35 | $settings_page = admin_url('options-general.php?page=koko-analytics-settings&tab=data');
36 | wp_safe_redirect(add_query_arg(['message' => urlencode(__('Statistics successfully reset', 'koko-analytics')) ], $settings_page));
37 | exit;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Normalizers/Path.php:
--------------------------------------------------------------------------------
1 | 1, 'p' => 1, 'tag' => 1, 'cat' => 1, 'product' => 1, 'attachment_id' => 1]));
22 |
23 | // trim trailing question mark & replace url with new sanitized url
24 | $value = rtrim($value, '?');
25 | }
26 |
27 | // in case wordpress is served from a subdirectory, use the path relative to the wordpress root page
28 | $home_path = parse_url(home_url(''), PHP_URL_PATH);
29 | if ($home_path && $home_path !== '/' && str_starts_with($value, $home_path)) {
30 | $value = substr($value, strlen($home_path));
31 | }
32 |
33 | // if value ends with /amp/, remove suffix (but leave trailing slash)
34 | if (str_ends_with($value, '/amp/')) {
35 | $value = substr($value, 0, strlen($value) - 4);
36 | }
37 |
38 | return $value;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/performance.php:
--------------------------------------------------------------------------------
1 |
6 |
7 | = esc_html__('Performance', 'koko-analytics') ?>
8 |
9 |
10 |
11 |
✓
12 |
13 |
14 |
20 |
' . Endpoint_Installer::get_file_name() . ''); ?>
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/Command.php:
--------------------------------------------------------------------------------
1 | $vars['post_type'],
38 | 'number' => 100, // to support blocks with pagination
39 | 'days' => 30,
40 | ]);
41 |
42 | // WP_Query checks for post__in argument using ! empty, so we pass a dummy array here in case we didn't find any posts with stats over last N days
43 | if (count($post_ids) === 0) {
44 | $post_ids = [ 0 ];
45 | }
46 |
47 | $vars['ignore_sticky_posts'] = true;
48 | $vars['orderby'] = 'post__in';
49 | $vars['post__in'] = $post_ids;
50 | return $vars;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/benchmarks/run-benchmark.php:
--------------------------------------------------------------------------------
1 | memory;
39 | $times[] = $data->time;
40 | }
41 |
42 | [$time_min, $time_mean, $time_max] = analyze($times);
43 |
44 | echo $opcache ? "with opcache: " : "without opcache: ";
45 | echo "min: $time_min\tmean: $time_mean\tmax: $time_max\n";
46 | }
47 |
48 | $root = __DIR__;
49 | $ph = popen("php -S localhost:8080 -q -t {$root} &2>/dev/null", "r");
50 | if (!$ph) {
51 | echo "Error starting HTTP server\n";
52 | exit(1);
53 | }
54 | sleep(2);
55 |
56 | bench(100, true);
57 | bench(100, false);
58 |
59 | pclose($ph);
60 |
--------------------------------------------------------------------------------
/src/Resources/views/standalone.php:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Koko Analytics
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/assets/src/js/dashboard.js:
--------------------------------------------------------------------------------
1 | // update date_start and date_end 's whenever a preset is selected
2 | var datePresetSelect = document.querySelector('#ka-date-presets');
3 | var dateStartInput = document.querySelector('#ka-date-start');
4 | var dateEndInput = document.querySelector('#ka-date-end');
5 | datePresetSelect && datePresetSelect.addEventListener('change', function() {
6 | dateStartInput.disabled = true;
7 | dateEndInput.disabled = true;
8 | this.form.submit();
9 | });
10 |
11 | // set value for date preset/view to custom whenever date input is used
12 | function setPresetToCustom() {
13 | datePresetSelect.value = 'custom';
14 | }
15 |
16 | dateStartInput && dateStartInput.addEventListener('change', setPresetToCustom);
17 | dateEndInput && dateEndInput.addEventListener('change', setPresetToCustom);
18 |
19 | // fill chart
20 | import {Chart} from './imports/chart.js';
21 | Chart();
22 |
23 | // click "prev date range" or "next date range" when using arrow keys
24 | document.addEventListener('keydown', function (evt) {
25 | if (evt.defaultPrevented) {
26 | return; // Do nothing if the event was already processed
27 | }
28 |
29 | switch (evt.key) {
30 | case 'ArrowLeft':
31 | document.querySelector('.js-quicknav-prev').click();
32 | break;
33 | case 'ArrowRight':
34 | document.querySelector('.js-quicknav-next').click();
35 | break;
36 | }
37 | })
38 |
39 | // every 61 seconds without mouse activity, reload the page (but only if tab is active)
40 | var reloadTimeout = window.setTimeout(reloadIfActive, 61000);
41 | function reloadIfActive() {
42 | if (!document.hidden) {
43 | window.location.reload();
44 | } else {
45 | // if document hidden, try again in 61s
46 | reloadTimeout = window.setTimeout(reloadIfActive, 61000);
47 | }
48 | }
49 | document.addEventListener('mouseover', function() {
50 | window.clearTimeout(reloadTimeout);
51 | reloadTimeout = window.setTimeout(reloadIfActive, 61000);
52 | })
53 |
--------------------------------------------------------------------------------
/src/Dashboard_Widget.php:
--------------------------------------------------------------------------------
1 | format('Y-m-d');
34 | $totals = $stats->get_totals($today, $today);
35 |
36 | // get realtime pageviews, but limit it to number of total pageviews today in case viewing shortly after midnight
37 | $realtime = min($totals->pageviews, get_realtime_pageview_count('-1 hour'));
38 |
39 | // get chart data
40 | $date_start = new DateTimeImmutable('-14 days', $timezone);
41 | $date_end = new DateTimeImmutable('now', $timezone);
42 | $chart_data = $stats->get_stats($date_start->format('Y-m-d'), $date_end->format('Y-m-d'), 'day');
43 |
44 | if ($number_of_top_items > 0) {
45 | $posts = $stats->get_posts($today, $today, 0, $number_of_top_items);
46 | $referrers = $stats->get_referrers($today, $today, 0, $number_of_top_items);
47 | }
48 |
49 | require KOKO_ANALYTICS_PLUGIN_DIR . '/src/Resources/views/dashboard-widget.php';
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Shortcodes/Shortcode_Most_Viewed_Posts.php:
--------------------------------------------------------------------------------
1 | 5,
21 | 'show_date' => false,
22 | 'days' => 30,
23 | 'post_type' => 'post',
24 | ];
25 | $args = shortcode_atts($allowed_args, $args, self::SHORTCODE);
26 | if ($args['show_date'] === "false") {
27 | $args['show_date'] = false;
28 | }
29 | $posts = get_most_viewed_posts($args);
30 |
31 | // If shortcode arguments did not return any results
32 | // Show a helpful message to editors and up
33 | if (count($posts) === 0 && current_user_can('edit_posts')) {
34 | return '' . esc_html__('Heads up! Your shortcode is working, but did not return any results. Please check your shortcode arguments.', 'koko-analytics') . '
';
35 | }
36 |
37 | $html = '';
38 | foreach ($posts as $p) {
39 | $post_title = get_the_title($p);
40 | $title = $post_title !== '' ? esc_html($post_title) : esc_html__('(no title)', 'koko-analytics');
41 | $permalink = esc_attr(get_the_permalink($p));
42 |
43 | $aria_current = '';
44 | if (get_queried_object_id() === $p->ID) {
45 | $aria_current = ' aria-current="page"';
46 | }
47 |
48 | $html .= '';
49 | $html .= "{$title} ";
50 |
51 | if ($args['show_date']) {
52 | $date = esc_html(get_the_date('', $p));
53 | $html .= " {$date} ";
54 | }
55 | $html .= ' ';
56 | }
57 | $html .= ' ';
58 | return $html;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/dashboard.php:
--------------------------------------------------------------------------------
1 | = esc_html__('Dashboard settings', 'koko-analytics') ?>
2 |
38 |
--------------------------------------------------------------------------------
/src/Shortcodes/Shortcode_Site_Counter.php:
--------------------------------------------------------------------------------
1 | 365 * 10,
31 | 'metric' => 'pageviews',
32 | 'global' => false,
33 | ];
34 | $args = shortcode_atts($default_args, $args, self::SHORTCODE);
35 | $args['days'] = abs((int) $args['days']);
36 | $path = $args['global'] && $args['global'] !== 'false' && $args['global'] !== '0' && $args['global'] !== 'no' ? '' : self::get_post_path();
37 |
38 |
39 |
40 | $start_date_str = $args['days'] === 0 ? 'today midnight' : "-{$args['days']} days";
41 | $timezone = wp_timezone();
42 | $start_date = (new DateTime($start_date_str, $timezone))->format('Y-m-d');
43 | $end_date = (new DateTime('tomorrow, midnight', $timezone))->format('Y-m-d');
44 |
45 | $cache_key = "ka_counter_" . md5("{$path}-{$args['metric']}-{$args['days']}");
46 | $count = get_transient($cache_key);
47 | if (false === $count) {
48 | $stats = new Stats();
49 | $totals = $stats->get_totals($start_date, $end_date, $path);
50 | $count = $args['metric'] === 'visitors' ? $totals->visitors : $totals->pageviews;
51 | set_transient($cache_key, $count, 5 * 60);
52 | }
53 |
54 | return '' . $count . ' ';
55 | }
56 |
57 | // Gets the path to whatever post is currently in "the loop"
58 | public static function get_post_path()
59 | {
60 | $permalink = get_the_permalink();
61 | $url_parts = parse_url($permalink);
62 | $path = $url_parts['path'];
63 | if (!empty($url_parts['query'])) {
64 | $path .= '?' . $url_parts['query'];
65 | }
66 | return Normalizer::path($path);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/tests/benchmarks/parse-url.php:
--------------------------------------------------------------------------------
1 | 1, 'p' => 1, 'tag' => 1, 'cat' => 1, 'product' => 1, 'attachment_id' => 1]));
20 |
21 | // trim trailing question mark & replace url with new sanitized url
22 | $url = rtrim($url, '?');
23 | }
24 |
25 | return $url;
26 | }
27 |
28 | function normalize_explode(string $url): string
29 | {
30 | // remove # from URL
31 | if (($pos = strpos($url, '#')) !== false) {
32 | $url = substr($url, 0, $pos);
33 | }
34 |
35 | // if URL contains query string, parse it and only keep certain parameters
36 | if (($pos = strpos($url, '?')) !== false) {
37 | $query_string = substr($url, $pos + 1);
38 | $url = substr($url, 0, $pos + 1);
39 | $allowed_params = [ 'page_id', 'p', 'tag', 'cat', 'product', 'attachment_id'];
40 |
41 | foreach (explode('&', $query_string) as $a) {
42 | $parts = explode('=', $a);
43 | $left = $parts[0];
44 | $right = $parts[1] ?? '';
45 |
46 | if (in_array($left, $allowed_params)) {
47 | $url .= $left;
48 |
49 | if ($right) {
50 | $url .= '=';
51 | $url .= $right;
52 | $url .= '&';
53 | }
54 | }
55 | }
56 |
57 | $url = rtrim($url, '?&');
58 | }
59 |
60 | return $url;
61 | }
62 |
63 |
64 | $n = 10000;
65 |
66 | $time = bench(function () {
67 | $normalized = normalize_with_parse_str("/about/?utm_source=source&utm_medium=medium&p=100&utm_campaign=campaign&");
68 | bench_assert("/about/?p=100", $normalized);
69 | }, $n);
70 |
71 | printf("normalize_with_parse_str:\t %.2f ns (%.2f per it)\n", $time, $time / $n);
72 |
73 | $time = bench(function () {
74 | $normalized = normalize_explode("/about/?utm_source=source&utm_medium=medium&p=100&utm_campaign=campaign&");
75 | bench_assert("/about/?p=100", $normalized);
76 | }, $n);
77 | printf("normalize_explode:\t %.2f ns (%.2f per it)\n", $time, $time / $n);
78 |
--------------------------------------------------------------------------------
/src/Fingerprinter.php:
--------------------------------------------------------------------------------
1 | ',
31 | 'Order deny,allow',
32 | 'Deny from all',
33 | '',
34 | '',
35 | 'Require all denied',
36 | ' ',
37 | '',
38 | ];
39 | file_put_contents("$sessions_dir/.htaccess", join(PHP_EOL, $lines));
40 | }
41 |
42 | public static function run_daily_maintenance(): void
43 | {
44 | $settings = get_settings();
45 | if ($settings['tracking_method'] !== 'fingerprint') {
46 | return;
47 | }
48 |
49 | $upload_dir = get_upload_dir();
50 | $sessions_dir = "{$upload_dir}/sessions";
51 | $seed_file = "{$sessions_dir}/.daily_seed";
52 |
53 | // ensure directory exists
54 | self::create_storage_dir();
55 |
56 | // remove every file in directory
57 | foreach (new \DirectoryIterator($sessions_dir) as $f) {
58 | if ($f->isDot()) {
59 | continue;
60 | }
61 |
62 | unlink($f->getPathname());
63 | }
64 |
65 | // create new seed file
66 | file_put_contents($seed_file, bin2hex(random_bytes(16)));
67 | }
68 |
69 | public static function setup_scheduled_event(): void
70 | {
71 | if (! wp_next_scheduled('koko_analytics_rotate_fingerprint_seed')) {
72 | $time_next_midnight = (new \DateTimeImmutable('tomorrow, midnight', wp_timezone()))->getTimestamp();
73 | wp_schedule_event($time_next_midnight, 'daily', 'koko_analytics_rotate_fingerprint_seed');
74 | }
75 | }
76 |
77 | public static function clear_scheduled_event(): void
78 | {
79 | wp_clear_scheduled_hook('koko_analytics_rotate_fingerprint_seed');
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/Resources/functions/global.php:
--------------------------------------------------------------------------------
1 | The number of results to return
26 | * `post_type` => A single post type or an array of post types to return
27 | * `days` => Specified the last X number of days for which the most viewed posts should be returned
28 | *
29 | * @param array $args
30 | * @return array
31 | * @since 1.1
32 | */
33 | function koko_analytics_get_most_viewed_posts(array $args = []): array
34 | {
35 | return KokoAnalytics\get_most_viewed_posts($args);
36 | }
37 |
38 |
39 | /**
40 | * Returns the number of realtime pageviews, for example in the last hour or in the last 5 minutes.
41 | * Does not work with timestamps over 1 hour ago.
42 | *
43 | * Examples:
44 | * koko_analytics_get_realtime_pageview_count('-5 minutes');
45 | * koko_analytics_get_realtime_pageview_count('-1 hour');
46 | *
47 | * @since 1.1
48 | * @param null|string|int $since An integer timestamp (seconds since Unix epoch) or a relative time string in the format that strtotime() understands. Defaults to "-5 minutes"
49 | * @return int
50 | * @see strtotime
51 | */
52 | function koko_analytics_get_realtime_pageview_count($since = '-5 minutes'): int
53 | {
54 | return KokoAnalytics\get_realtime_pageview_count($since);
55 | }
56 |
57 | /**
58 | * Writes a new pageview to the buffer file, to be aggregated during the next time `koko_analytics_aggregate_stats` runs.
59 | *
60 | * @param string $path The (normalized) path which was viewed
61 | * @param int $post_id The post ID to increment the pageviews count for. 0 if not a singular post type.
62 | * @param bool $new_visitor Whether this is a new site visitor.
63 | * @param bool $unique_pageview Whether this was an unique pageview. (Ie the first time this visitor views this page today).
64 | * @param string $referrer_url External URL that this visitor came from, or empty string if direct traffic or coming from internal link.
65 | * @return bool
66 | * @since 1.1
67 | */
68 | function koko_analytics_track_pageview(string $path, int $post_id = 0, bool $new_visitor = false, bool $unique_pageview = false, string $referrer_url = ''): bool
69 | {
70 | $data = [
71 | 'p',
72 | \time(),
73 | $path,
74 | $post_id,
75 | (int) $new_visitor,
76 | (int) $unique_pageview,
77 | $referrer_url,
78 | ];
79 | return KokoAnalytics\collect_in_file($data);
80 | }
81 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/help.php:
--------------------------------------------------------------------------------
1 |
2 | = esc_html__('Help', 'koko-analytics') ?>
3 |
4 |
5 | ', ''); ?>
6 | ', ''); ?>
7 | ', ''); ?>
8 |
9 |
10 |
11 | 0) { ?>
30 |
38 |
39 |
40 |
41 |
42 |
= esc_html__('Debug info', 'koko-analytics') ?>
43 |
59 |
60 |
--------------------------------------------------------------------------------
/src/Admin/Data_Import.php:
--------------------------------------------------------------------------------
1 | urlencode(__('Something went wrong trying to process your import file.', 'koko-analytics'))], $settings_page));
25 | exit;
26 | }
27 |
28 | // don't accept MySQL blobs over 16 MB
29 | if ($_FILES['import-file']['size'] > 16000000) {
30 | wp_safe_redirect(add_query_arg(['error' => urlencode(__('Sorry, your import file is too large. Please import it into your database in some other way.', 'koko-analytics'))], $settings_page));
31 | exit;
32 | }
33 |
34 | // try to increase time limit
35 | @set_time_limit(300);
36 |
37 | // read SQL from upload file
38 | $sql = file_get_contents($_FILES['import-file']['tmp_name']);
39 |
40 | // verify file looks like a Koko Analytics export file
41 | if (!preg_match('/^(--|DELETE|SELECT|INSERT|TRUNCATE|CREATE|DROP)/', $sql)) {
42 | wp_safe_redirect(add_query_arg(['error' => urlencode(__('Sorry, the uploaded import file does not look like a Koko Analytics export file', 'koko-analytics')) ], $settings_page));
43 | exit;
44 | }
45 |
46 | // good to go, let's run the SQL
47 | try {
48 | self::run($sql);
49 | } catch (\Exception $e) {
50 | wp_safe_redirect(add_query_arg([ 'error' => urlencode(__('Something went wrong trying to process your import file.', 'koko-analytics') . "\n" . $e->getMessage()) ], $settings_page));
51 | exit;
52 | }
53 |
54 | // unlink tmp file
55 | unlink($_FILES['import-file']['tmp_name']);
56 |
57 | // redirect with success message
58 | wp_safe_redirect(add_query_arg([ 'message' => urlencode(__('Database was successfully imported from the given file', 'koko-analytics')) ], $settings_page));
59 | exit;
60 | }
61 |
62 | protected static function run(string $sql): void
63 | {
64 | if ($sql === '') {
65 | return;
66 | }
67 |
68 | /** @var \wpdb $wpdb */
69 | global $wpdb;
70 | $statements = explode(';', $sql);
71 | foreach ($statements as $statement) {
72 | // skip over empty statements
73 | $statement = trim($statement);
74 | if (!$statement) {
75 | continue;
76 | }
77 |
78 | $result = $wpdb->query($statement);
79 |
80 | if ($result === false) {
81 | throw new Exception($wpdb->last_error);
82 | }
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Migrations.php:
--------------------------------------------------------------------------------
1 | prefix = rtrim($prefix, '_');
24 | $this->option_name = str_ends_with($this->prefix, '_version') ? $this->prefix : "{$this->prefix}_version";
25 | $this->version_from = isset($_GET["{$this->prefix}_migrate_from_version"]) && current_user_can('manage_options') ? $_GET["{$this->prefix}_migrate_from_version"] : get_option($this->option_name, '0.0.0');
26 | $this->version_to = $version_to;
27 | $this->migrations_dir = $migrations_dir;
28 | }
29 |
30 | public function maybe_run(): void
31 | {
32 | if (\version_compare($this->version_from, $this->version_to, '>=')) {
33 | return;
34 | }
35 |
36 | // check if migrations not already running
37 | $transient_key = "{$this->prefix}_migrations_running";
38 | $transient_timeout = 10;
39 | $previous_run_start = get_transient($transient_key);
40 | if ($previous_run_start && $previous_run_start > time() - $transient_timeout) {
41 | return;
42 | }
43 |
44 | set_transient($transient_key, time(), $transient_timeout);
45 | $this->run();
46 | delete_transient($transient_key);
47 | }
48 |
49 | /**
50 | * Run the various migration files, all the way up to the latest version
51 | */
52 | protected function run(): void
53 | {
54 | $files = glob(rtrim($this->migrations_dir, '/') . '/*.php');
55 | if (! is_array($files)) {
56 | return;
57 | }
58 |
59 | // run each migration file
60 | foreach ($files as $file) {
61 | $this->handle_file($file);
62 | }
63 |
64 | // update database version to current code version
65 | update_option($this->option_name, $this->version_to, true);
66 | }
67 |
68 | /**
69 | * @param string Absolute path to migration file
70 | */
71 | protected function handle_file(string $file): void
72 | {
73 | $migration = basename($file);
74 | $parts = explode('-', $migration);
75 | $migration_version = $parts[0];
76 |
77 | // check if migration file is not for an even higher version
78 | if (version_compare($migration_version, $this->version_to, '>')) {
79 | return;
80 | }
81 |
82 | // check if we ran migration file before.
83 | if (version_compare($this->version_from, $migration_version, '>=')) {
84 | return;
85 | }
86 |
87 | // run migration file
88 | include $file;
89 |
90 | // update option so later runs start after this migration
91 | $this->version_from = $migration_version;
92 | update_option($this->option_name, $migration_version, true);
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/bin/create-package:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -euo pipefail
4 |
5 | # Check if VERSION argument was supplied
6 | if [ "$#" -lt 1 ]; then
7 | echo "1 parameters expected, $# found"
8 | echo "Usage: package.sh "
9 | exit 1
10 | fi
11 |
12 | PLUGIN_SLUG=$(basename "$PWD")
13 | PLUGIN_FILE="$PLUGIN_SLUG.php"
14 | VERSION=$1
15 | PACKAGE_FILE="$PWD/../$PLUGIN_SLUG-$VERSION.zip"
16 |
17 | # Check if we're inside plugin directory
18 | if [ ! -e "$PLUGIN_FILE" ]; then
19 | echo "Plugin entry file not found. Please run this command from inside the $PLUGIN_SLUG directory."
20 | exit 1
21 | fi
22 |
23 | # Check if there are uncommitted changes
24 | if [ -n "$(git status --porcelain)" ]; then
25 | echo "There are uncommitted changes. Please commit those changes before initiating a release."
26 | exit 1
27 | fi
28 |
29 | # Update referrer blocklist
30 | curl https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt -k -o data/referrer-blocklist
31 | git add data/referrer-blocklist || true
32 | git commit -m "update referrer blocklist from https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt" || true
33 |
34 | # Update external
35 | bin/update-external-strings || true
36 |
37 | # Build (optimized) client-side assets
38 | npm run build
39 |
40 | # Update version numbers in code
41 | sed -i "s/^Version:.*$/Version: $VERSION/g" "$PLUGIN_FILE"
42 | sed -i "s/define(\s*'\(.*_VERSION\)'\s*,\s*'.*'\s*);/define('\1', '$VERSION');/g" "$PLUGIN_FILE"
43 | sed -i "s/^Stable tag:.*$/Stable tag: $VERSION/g" "readme.txt"
44 |
45 | # Copy over changelog from CHANGELOG.md to readme.txt
46 | # Ref: https://git.sr.ht/~dvko/dotfiles/tree/master/item/bin/wp-update-changelog
47 | wp-update-changelog
48 |
49 | # Update git
50 | git add . -A || true
51 | git commit -m "v$VERSION" || true
52 |
53 | # Move up one directory level because we need plugin directory in ZIP file
54 | cd ..
55 |
56 | # Check if there is an existing file for this release already
57 | rm -f "$PACKAGE_FILE"
58 |
59 | # Create archive (excl. development files)
60 | zip -r "$PACKAGE_FILE" "$PLUGIN_SLUG" \
61 | -x "$PLUGIN_SLUG/.*" \
62 | -x "$PLUGIN_SLUG/bin/*" \
63 | -x "$PLUGIN_SLUG/vendor/*" \
64 | -x "$PLUGIN_SLUG/node_modules/*" \
65 | -x "$PLUGIN_SLUG/tests/*" \
66 | -x "$PLUGIN_SLUG/webpack.config.js" \
67 | -x "$PLUGIN_SLUG/eslint.config.mjs" \
68 | -x "$PLUGIN_SLUG/package.json" \
69 | -x "$PLUGIN_SLUG/package-lock.json" \
70 | -x "$PLUGIN_SLUG/composer.json" \
71 | -x "$PLUGIN_SLUG/*.lock" \
72 | -x "$PLUGIN_SLUG/phpcs.xml" \
73 | -x "$PLUGIN_SLUG/phpunit.xml.dist" \
74 | -x "$PLUGIN_SLUG/*.sh" \
75 | -x "$PLUGIN_SLUG/assets/src/*" \
76 | -x "$PLUGIN_SLUG/assets/dist/img/screenshot-*" \
77 | -x "$PLUGIN_SLUG/assets/dist/img/banner-*" \
78 | -x "$PLUGIN_SLUG/code-snippets/*"
79 |
80 | # Move back into plugin directory
81 | cd "$PLUGIN_SLUG"
82 |
83 | SIZE=$(ls -lh "$PACKAGE_FILE" | cut -d' ' -f5)
84 | echo "$(basename "$PACKAGE_FILE") created ($SIZE)"
85 |
86 | # Create tag in Git and push to remote
87 | printf "\nPush v$VERSION to GitHub (y)"
88 | read CONFIRM
89 | if [[ "$CONFIRM" != "n" ]]; then
90 | git tag "$VERSION"
91 | git push origin main
92 | git push origin "tags/$VERSION"
93 | fi;
94 |
--------------------------------------------------------------------------------
/src/Import/Importer.php:
--------------------------------------------------------------------------------
1 | urlencode($error)]);
23 | }
24 |
25 | /**
26 | * @param array $rows An array of arrays with the following elements: date, path, post_id, visitors, pageviews
27 | */
28 | protected static function bulk_insert_page_stats(array $rows): void
29 | {
30 | /** @var wpdb $wpdb */
31 | global $wpdb;
32 |
33 | // return early if nothing to do
34 | if (count($rows) == 0) {
35 | return;
36 | }
37 |
38 | $path_ids = Path_Repository::upsert(array_map(function ($r) {
39 | return $r[1];
40 | }, $rows));
41 |
42 | $values = [];
43 | foreach ($rows as $r) {
44 | array_push($values, $r[0], $path_ids[$r[1]], $r[2], $r[3], $r[4]);
45 | }
46 | $placeholders = rtrim(str_repeat('(%s,%d,%d,%d,%d),', count($rows)), ',');
47 |
48 | $query = $wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_post_stats(date, path_id, post_id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values);
49 | $wpdb->query($query);
50 |
51 | if ($wpdb->last_error !== '') {
52 | throw new Exception(__("A database error occurred: ", 'koko-analytics') . " {$wpdb->last_error}");
53 | }
54 | }
55 |
56 | /**
57 | * @param array $rows An array of arrays with the following elements: date, referrer, visitors, pageviews
58 | */
59 | protected static function bulk_insert_referrer_stats(array $rows): void
60 | {
61 | /** @var wpdb $wpdb */
62 | global $wpdb;
63 |
64 | // return early if nothing to do
65 | if (count($rows) == 0) {
66 | return;
67 | }
68 |
69 | $ids = Referrer_Repository::upsert(array_map(function ($r) {
70 | return $r[1];
71 | }, $rows));
72 |
73 | $values = [];
74 | foreach ($rows as $r) {
75 | array_push($values, $r[0], $ids[$r[1]], $r[2], $r[3]);
76 | }
77 | $placeholders = rtrim(str_repeat('(%s,%d,%d,%d),', count($rows)), ',');
78 |
79 | $query = $wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_referrer_stats(date, id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values);
80 | $wpdb->query($query);
81 |
82 | if ($wpdb->last_error !== '') {
83 | throw new Exception(__("A database error occurred: ", 'koko-analytics') . " {$wpdb->last_error}");
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/Aggregator.php:
--------------------------------------------------------------------------------
1 | ') {
60 | continue;
61 | }
62 |
63 | $params = \unserialize($line, ['allowed_classes' => false]);
64 | if (! \is_array($params)) {
65 | error_log('Koko Analytics: unserialize error encountered while processing line in buffer file');
66 | continue;
67 | }
68 | $type = \array_shift($params);
69 |
70 | // core aggregator
71 | $pageview_aggregator->line($type, $params);
72 |
73 | // add-on aggregators
74 | do_action('koko_analytics_aggregate_line', $type, $params);
75 | }
76 |
77 | // close file & remove it from filesystem
78 | \fclose($file_handle);
79 | \unlink($tmp_filename);
80 |
81 | // tell aggregators to write their results to the database
82 | $pageview_aggregator->finish();
83 | do_action('koko_analytics_aggregate_finish');
84 | }
85 |
86 | public static function setup_scheduled_event(): void
87 | {
88 | if (! wp_next_scheduled('koko_analytics_aggregate_stats')) {
89 | wp_schedule_event(time() + 60, 'koko_analytics_stats_aggregate_interval', 'koko_analytics_aggregate_stats');
90 | }
91 | }
92 |
93 | public static function clear_scheduled_event(): void
94 | {
95 | wp_clear_scheduled_hook('koko_analytics_aggregate_stats');
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/tests/mocks.php:
--------------------------------------------------------------------------------
1 | '/tmp',
132 | ];
133 | }
134 |
135 | function wp_remote_get($url)
136 | {
137 | return null;
138 | }
139 |
140 | function wp_remote_retrieve_response_code($response)
141 | {
142 | return '';
143 | }
144 |
145 | function wp_remote_retrieve_headers($response)
146 | {
147 | return [];
148 | }
149 |
150 | function is_wp_error($thing)
151 | {
152 | return false;
153 | }
154 |
155 | function _deprecated_function($function, $version, $replacement)
156 | {
157 | }
158 |
159 | function wp_timezone(): DateTimeZone
160 | {
161 | return new DateTimeZone('UTC');
162 | }
163 |
164 | class wpdb_mock
165 | {
166 | public $prefix = '';
167 | public function query($sql)
168 | {
169 | return null;
170 | }
171 |
172 | public function prepare($query, ...$params)
173 | {
174 | return $query;
175 | }
176 | }
177 |
178 | global $wpdb;
179 | $wpdb = new wpdb_mock();
180 |
--------------------------------------------------------------------------------
/src/Normalizers/Referrer.php:
--------------------------------------------------------------------------------
1 | 'https://$3.$1',
18 | '/^android-app:\/\/m\.facebook\.com/' => 'https://facebook.com',
19 |
20 | // popular iOS apps
21 | '/^ios-app:\/\/429047995.*/' => 'https://pinterest.com',
22 | '/^ios-app:\/\/1064216828.*/' => 'https://reddit.com',
23 | '/^ios-app:\/\/284882215.*/' => 'https://facebook.com',
24 | '/^ios-app:\/\/389801252.*/' => 'https://instagram.com',
25 |
26 | // popular websites
27 | '/^https?:\/\/(?:www\.)?(google|bing|ecosia)\.([a-z]{2,4}(?:\.[a-z]{2,4})?)(?:\/search|\/url)?/' => 'https://$1.$2',
28 | '/^https?:\/\/(?:[a-z-]+\.)?l?facebook\.com(?:\/l\.php)?/' => 'https://facebook.com',
29 | '/^https?:\/\/(?:[a-z-]+\.)?l?instagram\.com(?:\/l\.php)?/' => 'https://instagram.com',
30 | '/^https?:\/\/(?:[a-z-]+\.)?linkedin\.com\/feed.*/' => 'https://linkedin.com',
31 | '/^https?:\/\/(?:[a-z-]+\.)?pinterest\.com/' => 'https://pinterest.com',
32 | '/^https?:\/\/(?:[a-z-]+\.)?baidu\.com.*/' => 'https://baidu.com',
33 | '/^https?:\/\/(?:[a-z-]+\.)?yandex\.ru\/.*/' => 'https://yandex.ru',
34 | '/^https?:\/\/(?:[a-z-]+\.)?search\.yahoo\.com\/.*/' => 'https://search.yahoo.com',
35 | '/^https?:\/\/(?:[a-z-]+\.)?reddit\.com.*/' => 'https://reddit.com',
36 | '/^https?:\/\/(?:[a-z0-9]{1,8}\.)+sendib(?:m|t)[0-9]\.com.*/' => 'https://brevo.com',
37 |
38 | ];
39 |
40 | $aggregations = apply_filters('koko_analytics_url_aggregations', $aggregations);
41 | $normalized_value = (string) preg_replace(array_keys($aggregations), array_values($aggregations), $value, 1);
42 | if (preg_last_error() !== PREG_NO_ERROR) {
43 | error_log("Koko Analytics: preg_replace error in Referrer::normalize('$value'): " . preg_last_error_msg());
44 | return $value;
45 | }
46 | $value = $normalized_value;
47 |
48 | // limit resulting value to just host
49 | $url_parts = parse_url($value);
50 |
51 | // check for seriously malformed url's
52 | if ($url_parts === false || empty($url_parts['host'])) {
53 | return '';
54 | }
55 | $result = $url_parts['host'];
56 |
57 | // strip www. prefix
58 | if (str_starts_with($result, 'www.')) {
59 | $result = substr($result, 4);
60 | }
61 |
62 | // add path if domain is whitelisted
63 | $whitelisted_domains = ['wordpress.org', 'kokoanalytics.com', 'github.com', 'reddit.com', 'indiehackers.com'];
64 | $whitelisted_domains = apply_filters('koko_analytics_whitelisted_referrer_domains', $whitelisted_domains);
65 | if (in_array($result, $whitelisted_domains) && !empty($url_parts['path']) && $url_parts['path'] !== '/') {
66 | $result .= $url_parts['path'];
67 | }
68 |
69 | return $result;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/tests/FunctionsTest.php:
--------------------------------------------------------------------------------
1 | assertEquals(extract_pageview_data([]), []);
19 | $this->assertEquals(extract_pageview_data(['r' => 'http://www.kokoanalytics.com']), []);
20 |
21 | // complete but invalid
22 | $this->assertEquals(extract_pageview_data(['po' => '']), []);
23 | $this->assertEquals(extract_pageview_data(['po' => '1', 'r' => 'not an url']), []);
24 |
25 | // complete and valid
26 | foreach (
27 | [
28 | [['pa' => '/', 'po' => '1'], ['p', null, '/', 1, 1, 1, '']],
29 |
30 | ] as [$input, $expected]
31 | ) {
32 | $actual = extract_pageview_data($input);
33 | $this->assertGreaterThan(0, count($actual));
34 | $this->assertEquals($expected[0], $actual[0]); // type indicator
35 | $this->assertIsInt($actual[1]); // timestamp
36 | $this->assertEquals($expected[2], $actual[2]); // path
37 | $this->assertEquals($expected[3], $actual[3]); // post id
38 | $this->assertEquals($expected[4], $actual[4]);
39 | $this->assertEquals($expected[5], $actual[5]);
40 | $this->assertEquals($expected[6], $actual[6]);
41 | }
42 | }
43 |
44 | public function testExtractEventData(): void
45 | {
46 | // incomplete
47 | $this->assertEquals(extract_event_data([]), []);
48 | $this->assertEquals(extract_event_data(['e' => 'Event']), []);
49 | $this->assertEquals(extract_event_data(['p' => 'Param']), []);
50 | $this->assertEquals(extract_event_data(['v' => '1']), []);
51 | $this->assertEquals(extract_event_data(['e' => 'Event', 'v' => '1']), []);
52 | $this->assertEquals(extract_event_data(['p' => 'Param', 'v' => '1']), []);
53 |
54 | // complete but invalid
55 | $this->assertEquals(extract_event_data(['e' => 'Event', 'p' => 'Param', 'v' => 'nan']), []);
56 | $this->assertEquals(extract_event_data(['e' => '', 'p' => 'Param', 'v' => '100']), []);
57 |
58 | // complete and valid
59 | $actual = extract_event_data(['e' => 'Event', 'p' => 'Param', 'v' => '100']);
60 | $expected = ['e', 'Event', 'Param', 1, 100];
61 | $this->assertEquals($expected[0], $actual[0]);
62 | $this->assertEquals($expected[1], $actual[1]);
63 | $this->assertEquals($expected[2], $actual[2]);
64 | $this->assertEquals($expected[3], $actual[3]);
65 | $this->assertEquals($expected[4], $actual[4]);
66 | }
67 |
68 | public function testGetClientIp(): void
69 | {
70 | $this->assertEquals(get_client_ip(), '');
71 |
72 | $_SERVER['REMOTE_ADDR'] = '1.1.1.1';
73 | $this->assertEquals(get_client_ip(), '1.1.1.1');
74 |
75 | $_SERVER['HTTP_X_FORWARDED_FOR'] = '2.2.2.2';
76 | $this->assertEquals(get_client_ip(), '2.2.2.2');
77 |
78 | $_SERVER['HTTP_X_FORWARDED_FOR'] = '3.3.3.3, 2.2.2.2';
79 | $this->assertEquals(get_client_ip(), '3.3.3.3');
80 |
81 | $_SERVER['HTTP_X_FORWARDED_FOR'] = 'not-an-ip';
82 | $this->assertEquals(get_client_ip(), '1.1.1.1');
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/Notice_Pro.php:
--------------------------------------------------------------------------------
1 | maybe_show();
16 | }
17 |
18 | public function get_settings(): array
19 | {
20 | $settings = get_settings();
21 | $defaults = [
22 | 'timestamp_installed' => null,
23 | 'dismissed' => false,
24 | ];
25 | $settings['notice_pro'] = array_merge($defaults, $settings['notice_pro'] ?? []);
26 | return $settings;
27 | }
28 |
29 | private function get_setting(string $key)
30 | {
31 | $settings = $this->get_settings();
32 | return $settings['notice_pro'][$key];
33 | }
34 |
35 | private function update_setting(string $key, $value): void
36 | {
37 | $settings = $this->get_settings();
38 | $settings['notice_pro'][$key] = $value;
39 | update_option('koko_analytics_settings', $settings, true);
40 | }
41 |
42 | public function maybe_show(): void
43 | {
44 | // don't show if user doesn't have capability for managing koko analytics
45 | // don't show if Koko Analytics Pro is installed
46 | if (!current_user_can('manage_koko_analytics') || defined('KOKO_ANALYTICS_PRO_VERSION')) {
47 | return;
48 | }
49 |
50 | if (isset($_GET['ka-notice-pro-dismiss'])) {
51 | $this->update_setting('dismissed', true);
52 | return;
53 | }
54 |
55 | $date_installed = $this->get_setting('timestamp_installed');
56 |
57 | // if first time loading dashboard, don't show
58 | if ($date_installed === null) {
59 | $this->update_setting('timestamp_installed', time());
60 | return;
61 | }
62 |
63 | // if installed less than 30 days ago, don't show
64 | if ($date_installed > time() - (86400 * 30)) {
65 | return;
66 | }
67 |
68 | // if previously dismissed, don't show
69 | if ($this->get_setting('dismissed')) {
70 | return;
71 | }
72 |
73 | ?>
74 |
75 |
76 |
77 |
78 |
79 |
80 | = esc_html__('If you enjoy using this free plugin, consider helping us out by:', 'koko-analytics'); ?>
81 |
86 |
87 |
88 |
89 |
90 |
19 |
20 |
21 |
22 |
23 |
29 |
30 |
38 |
39 | 0 && (count($posts) > 0 || count($referrers) > 0)) { ?>
40 |
41 |
42 | 0) { ?>
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | pageviews); ?>
51 | label); ?>
52 |
53 |
54 |
55 |
56 |
57 | 0) { ?>
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | pageviews); ?>
66 | url)); ?>
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/assets/src/js/imports/chart.js:
--------------------------------------------------------------------------------
1 | var chart = document.querySelector('#ka-chart');
2 | if (chart) {
3 | var tooltip = document.querySelector('.ka-chart--tooltip');
4 | var arrow = document.querySelector('.ka-chart--tooltip-arrow');
5 | var bars = chart.querySelectorAll('.bars g');
6 | var barWidth;
7 |
8 | tooltip.remove();
9 | document.body.appendChild(tooltip);
10 |
11 | // event listener for removing tooltip
12 | document.body.addEventListener('mouseover', function(e) {
13 | if (tooltip.style.display !== 'none' && !chart.contains(e.target) && !tooltip.contains(e.target)) {
14 | tooltip.style.display = 'none';
15 | }
16 | });
17 |
18 | // event listener for showing tooltip
19 | chart.addEventListener('mouseover', function(e) {
20 | if (e.target.tagName !== 'rect') {
21 | return;
22 | }
23 | // update tooltip content
24 | var data = e.target.parentElement.dataset;
25 | tooltip.querySelector('.ka-chart--tooltip-heading').textContent = data.date;
26 | tooltip.querySelector('.ka--visitors').children[0].textContent = data.visitors;
27 | tooltip.querySelector('.ka--pageviews').children[0].textContent = data.pageviews;
28 |
29 | // set tooltip position relative to top-left of document
30 | var availWidth = document.body.clientWidth;
31 | tooltip.style.display = 'block';
32 | var scrollY = window.pageYOffset !== undefined ? window.pageYOffset : window.scrollTop
33 | var scrollX = 0; //window.pageXOffset !== undefined ? window.pageXOffset : window.scrollLeft
34 | var styles = e.target.parentElement.getBoundingClientRect() // element
35 | var left = Math.round(styles.left + scrollX - 0.5 * tooltip.clientWidth + 0.5 * barWidth);
36 | var top = Math.round(styles.top + scrollY - tooltip.clientHeight);
37 | var offCenter = 0;
38 |
39 | // if tooltip goes off the screen, position it a bit off center
40 | if (left < 12) {
41 | offCenter = -left + 12;
42 | } else if (left + tooltip.clientWidth > availWidth - 12) {
43 | offCenter = availWidth - (left + tooltip.clientWidth) - 12;
44 | }
45 |
46 | // shift tooltip to the right (or left)
47 | left += offCenter;
48 |
49 | // shift arrow to the left (or right)
50 | arrow.style.marginLeft = offCenter === 0 ? 'auto' : ((0.5 * tooltip.clientWidth) - 6 - offCenter) + 'px';
51 | tooltip.style.left = left + 'px';
52 | tooltip.style.top = top + 'px';
53 | })
54 | }
55 |
56 | export function Chart() {
57 | if (!chart) return;
58 |
59 | var yTicks = chart.querySelectorAll('.axes-y text');
60 | var i;
61 | var leftOffset = 0;
62 | for (i = 0; i < yTicks.length; i++) {
63 | leftOffset = Math.max(leftOffset, 8 + Math.max(5, yTicks[i].textContent.length * 8));
64 | }
65 | var tickWidth = Math.max(1, (chart.clientWidth - leftOffset) / bars.length);
66 | barWidth = Math.max(1, tickWidth - 2);
67 |
68 | // update width of each bar now that we know the client width
69 | bars[0].parentElement.style.display = 'none';
70 | for (i = 0; i < bars.length; i++) {
71 | var x = i * tickWidth + leftOffset + 1;
72 |
73 | // pageviews
74 | bars[i].children[0].setAttribute('x', x);
75 | bars[i].children[0].setAttribute('width', barWidth);
76 |
77 | // visitors
78 | bars[i].children[1].setAttribute('x', x);
79 | bars[i].children[1].setAttribute('width', barWidth);
80 |
81 | // tick
82 | bars[i].children[2].style.display = barWidth === 1 ? 'none' : '';
83 | x = i * tickWidth + leftOffset + 0.5 * tickWidth;
84 | bars[i].children[2].setAttribute('x1', x);
85 | bars[i].children[2].setAttribute('x2', x);
86 | }
87 |
88 | bars[0].parentElement.style.display = '';
89 | }
90 |
91 |
92 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/plausible_importer.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | []]); ?>
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
60 |
--------------------------------------------------------------------------------
/src/Pruner.php:
--------------------------------------------------------------------------------
1 | format('Y-m-d');
36 |
37 | // delete stats older than date above
38 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_site_stats WHERE date < %s", $date));
39 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_post_stats WHERE date < %s", $date));
40 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_referrer_stats WHERE date < %s", $date));
41 |
42 | self::delete_orphaned_referrer_urls();
43 | self::delete_orphaned_paths();
44 | self::delete_blocked_referrers();
45 | }
46 |
47 | protected static function delete_orphaned_referrer_urls(): void
48 | {
49 | /** @var \wpdb $wpdb */
50 | global $wpdb;
51 |
52 | // delete unused referrer urls
53 | $results = $wpdb->get_results("SELECT id FROM {$wpdb->prefix}koko_analytics_referrer_urls WHERE id NOT IN (SELECT DISTINCT(id) FROM {$wpdb->prefix}koko_analytics_referrer_stats)");
54 |
55 | // we explicitly delete the rows one-by-one here because the bulk with subquery approach we used before
56 | // would hang on certain MySQL installations (according to user reports)
57 | foreach ($results as $r) {
58 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_referrer_urls WHERE id = %d LIMIT 1", [$r->id]));
59 | }
60 | }
61 |
62 | protected static function delete_orphaned_paths(): void
63 | {
64 | /** @var \wpdb $wpdb */
65 | global $wpdb;
66 |
67 | $results = $wpdb->get_results("SELECT id FROM {$wpdb->prefix}koko_analytics_paths WHERE id NOT IN (SELECT DISTINCT(path_id) FROM {$wpdb->prefix}koko_analytics_post_stats)");
68 |
69 | // we explicitly delete the rows one-by-one here because the bulk with subquery approach we used before
70 | // would hang on certain MySQL installations (according to user reports)
71 | foreach ($results as $r) {
72 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_paths WHERE id = %d LIMIT 1", [$r->id]));
73 | }
74 | }
75 |
76 | protected static function delete_blocked_referrers(): void
77 | {
78 | global $wpdb;
79 |
80 | $blocklist = new Blocklist();
81 | $list = array_merge($blocklist->read(), apply_filters('koko_analytics_referrer_blocklist', []));
82 | $count = count($list);
83 |
84 | // process list in batches of 100
85 | for ($offset = 0; $offset < $count; $offset += 100) {
86 | $chunk = array_slice($list, $offset, 100);
87 | $chunk = array_map(function ($v) use ($wpdb) {
88 | return $wpdb->esc_like("%{$v}%");
89 | }, $chunk);
90 |
91 | $where = str_repeat("url LIKE %s OR ", count($chunk));
92 | $where = substr($where, 0, strlen($where) - 4);
93 |
94 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_referrer_urls WHERE {$where}", $chunk));
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Koko Analytics for WordPress
2 | ===========
3 | [](https://raw.githubusercontent.com/ibericode/koko-analytics/master/LICENSE)
4 | [](https://wordpress.org/plugins/koko-analytics/advanced/)
5 | [](https://wordpress.org/support/plugin/koko-analytics/reviews/)
6 | [](https://pagespeed.web.dev/analysis/https-www-kokoanalytics-com/e8d4epi2jf?form_factor=mobile)
7 |
8 | **[Koko Analytics](https://www.kokoanalytics.com/) is a fast, privacy-friendly, open-source analytics plugin for WordPress — no cookies, no tracking, no external services.**
9 |
10 |
11 |
12 | Screenshot of the Koko Analytics dashboard. You can view a live demo here .
13 |
14 |
15 | ---
16 |
17 | ## Why Koko Analytics?
18 |
19 | Koko Analytics is a simple replacement for Google Analytics that:
20 |
21 | - Works out-of-the-box (no setup wizard or API keys)
22 | - Stores all data on your server
23 | - Tracks only aggregated counts, never personal data
24 | - Respects GDPR and CCPA by design
25 | - Loads lightning-fast with a script under 500 bytes
26 |
27 | [See All Features →](https://www.kokoanalytics.com/features/)
28 |
29 | ---
30 |
31 | ### Koko Analytics Pro (optional add-on)
32 |
33 | Upgrade for advanced insights while keeping your visitors’ privacy intact:
34 |
35 | - 🌍 [Geo-location](https://www.kokoanalytics.com/features/geo-location/)
36 | - 🔗 [Event Tracking](https://www.kokoanalytics.com/features/custom-event-tracking/)
37 | - 📨 [Email Reports](https://www.kokoanalytics.com/features/email-reports/)
38 | - 🛠 [CSV Export](https://www.kokoanalytics.com/features/csv-export/)
39 | - 📈 [Pageviews Column in WP Admin](https://www.kokoanalytics.com/features/pageviews-column/)
40 | - 📊 [Admin Bar stats](https://www.kokoanalytics.com/features/admin-bar/)
41 | - 🚨 [Traffic Spike Notifications](https://www.kokoanalytics.com/features/traffic-spike-notifications/)
42 |
43 | [Get Koko Analytics Pro →](https://www.kokoanalytics.com/pricing/)
44 |
45 | ---
46 |
47 | ## Quick Start
48 |
49 | ### Requirements
50 |
51 | - WordPress 6.0+
52 | - PHP 7.4+ (latest recommended)
53 |
54 | ### Install from WordPress.org
55 |
56 | 1. In your WordPress dashboard, go to **Plugins → Add New**
57 | 1. Search for “Koko Analytics”
58 | 1. Click **Install** and **Activate** — stats start recording immediately
59 |
60 | ### Install from GitHub (latest development version)
61 |
62 | ```sh
63 | git clone git@github.com:ibericode/koko-analytics.git wp-content/plugins/koko-analytics
64 | cd wp-content/plugins/koko-analytics
65 | composer install
66 | npm install
67 | npm run build
68 | ```
69 |
70 | ---
71 |
72 | ## Usage
73 |
74 | Once activated, visit Dashboard → Analytics in WordPress to view your stats.
75 |
76 | See our [Knowledge Base](https://www.kokoanalytics.com/kb/) for advanced configuration, REST API usage, and code snippets.
77 |
78 | ---
79 |
80 | ## Contributing
81 |
82 | Koko Analytics is open source and community-driven. You can:
83 |
84 | - ⭐ Star this repo
85 | - 🗣 [Suggest & vote on features](https://github.com/ibericode/koko-analytics/discussions)
86 | - 🌍 [Translate the plugin](https://translate.wordpress.org/projects/wp-plugins/koko-analytics/stable/)
87 | - 📝 Write about it on your blog or social media
88 | - 💖 [Upgrade to Koko Analytics Pro](https://www.kokoanalytics.com/pricing/) to fund development
89 |
90 | ---
91 |
92 | ## License
93 |
94 | GNU General Public License v3.0
95 |
--------------------------------------------------------------------------------
/src/Script_Loader.php:
--------------------------------------------------------------------------------
1 | ' . PHP_EOL;
27 | wp_print_inline_script_tag(file_get_contents(KOKO_ANALYTICS_PLUGIN_DIR . '/assets/dist/js/script.js'));
28 | echo PHP_EOL;
29 | }
30 |
31 | /**
32 | * Returns the internal ID of the page or post that is being shown.
33 | *
34 | * If page is not a singular object, the function returns 0 if it is the front page (from Settings)
35 | */
36 | private static function get_post_id(): int
37 | {
38 | if (is_singular()) {
39 | return get_queried_object_id();
40 | }
41 |
42 | return 0;
43 | }
44 |
45 | private static function get_tracker_url(): string
46 | {
47 | // People can create their own endpoint and define it through this constant
48 | if (\defined('KOKO_ANALYTICS_CUSTOM_ENDPOINT') && KOKO_ANALYTICS_CUSTOM_ENDPOINT) {
49 | // custom custom endpoint
50 | return site_url(KOKO_ANALYTICS_CUSTOM_ENDPOINT);
51 | } elseif (using_custom_endpoint()) {
52 | // default custom endpoint
53 | return site_url('/koko-analytics-collect.php');
54 | }
55 |
56 | // default URL (which includes WordPress)
57 | return admin_url('admin-ajax.php?action=koko_analytics_collect');
58 | }
59 |
60 | public static function get_request_path(): string
61 | {
62 | return Normalizer::path(trim($_SERVER["REQUEST_URI"] ?? ''));
63 | }
64 |
65 | public static function print_js_object()
66 | {
67 | $settings = get_settings();
68 | $script_config = [
69 | // the URL of the tracking endpoint
70 | 'url' => self::get_tracker_url(),
71 |
72 | // root URL of site
73 | 'site_url' => get_home_url(),
74 |
75 | 'post_id' => self::get_post_id(),
76 | 'path' => self::get_request_path(),
77 |
78 | // tracking method to use (passed to endpoint)
79 | 'method' => $settings['tracking_method'],
80 |
81 | // for backwards compatibility with older versions
82 | // some users set this value from other client-side scripts, ie cookie consent banners
83 | // if true, takes priority of the method property defined above
84 | 'use_cookie' => $settings['tracking_method'] === 'cookie',
85 | ];
86 | $data = 'window.koko_analytics = ' . \json_encode($script_config) . ';';
87 | wp_print_inline_script_tag($data);
88 | }
89 |
90 | public static function print_amp_analytics_tag()
91 | {
92 | $settings = get_settings();
93 | $data = [
94 | 'm' => $settings['tracking_method'][0],
95 | 'po' => self::get_post_id(),
96 | 'pa' => self::get_request_path(),
97 | ];
98 | $url = add_query_arg($data, self::get_tracker_url());
99 | $config = [
100 | 'requests' => [
101 | 'pageview' => $url,
102 | ],
103 | 'triggers' => [
104 | 'trackPageview' => [
105 | 'on' => 'visible',
106 | 'request' => 'pageview',
107 | ],
108 | ],
109 | ];
110 |
111 | echo ' ';
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/data.php:
--------------------------------------------------------------------------------
1 | = esc_html__('Data settings', 'koko-analytics') ?>
2 |
16 |
17 |
32 |
33 |
34 |
35 |
36 |
= esc_html__('If you\'re coming from another statistics plugin, you may be able to import your historical data using one of our importers listed below.', 'koko-analytics'); ?>
37 |
38 |
42 |
43 |
44 |
45 |
55 |
56 |
66 |
--------------------------------------------------------------------------------
/src/Resources/views/settings-page.php:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
← = esc_html__('Back to stats', 'koko-analytics') ?>
9 |
10 |
11 |
12 |
13 |
14 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | = esc_html($_GET['error']); ?>
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | = esc_html($_GET['message']); ?>
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/src/Admin/Pages.php:
--------------------------------------------------------------------------------
1 | ';
28 | echo esc_html__('There seems to be an issue with your site\'s WP Cron configuration that prevents Koko Analytics from automatically processing your statistics.', 'koko-analytics');
29 | echo ' ';
30 | echo esc_html__('If you\'re not sure what this is about, please ask your webhost to look into this.', 'koko-analytics');
31 | echo '
';
32 | }
33 |
34 | // determine whether buffer file is writable
35 | $buffer_filename = get_buffer_filename();
36 | $buffer_dirname = dirname($buffer_filename);
37 | $is_buffer_dir_writable = wp_mkdir_p($buffer_dirname) && is_writable($buffer_dirname);
38 |
39 | if (false === $is_buffer_dir_writable) {
40 | echo '';
41 | echo wp_kses(\sprintf(__('Koko Analytics is unable to write to the %s directory. Please update the file permissions so that your web server can write to it.', 'koko-analytics'), $buffer_dirname), ['code' => []]);
42 | echo '
';
43 | }
44 |
45 | $dashboard = new Dashboard();
46 | $dashboard->show();
47 | }
48 |
49 | public static function show_settings_page(): void
50 | {
51 | if (!current_user_can('manage_koko_analytics')) {
52 | return;
53 | }
54 |
55 | $allowed_tabs = [
56 | 'tracking',
57 | 'dashboard',
58 | 'events',
59 | 'email-reports',
60 | 'data',
61 | 'performance',
62 | 'help',
63 | 'jetpack_importer',
64 | 'plausible_importer',
65 | ];
66 | $active_tab = isset($_GET['tab']) && in_array($_GET['tab'], $allowed_tabs) ? $_GET['tab'] : 'tracking';
67 |
68 | $settings = get_settings();
69 | $using_custom_endpoint = using_custom_endpoint();
70 | $user_roles = self::get_available_roles();
71 | $date_presets = (new Dashboard())->get_date_presets();
72 | $public_dashboard_url = Router::url('dashboard-standalone');
73 |
74 | require KOKO_ANALYTICS_PLUGIN_DIR . '/src/Resources/views/settings-page.php';
75 | }
76 |
77 |
78 | private static function get_available_roles(): array
79 | {
80 | $roles = [];
81 | foreach (wp_roles()->roles as $key => $role) {
82 | $roles[$key] = $role['name'];
83 | }
84 | return $roles;
85 | }
86 |
87 | /**
88 | * Checks to see if the cron event is correctly scheduled and running periodically
89 | * If the cron event is somehow not scheduled, this will schedule it again.
90 | */
91 | private static function is_cron_event_working(): bool
92 | {
93 | // Always return true on localhost / dev-ish environments
94 | $site_url = get_site_url();
95 | $parts = parse_url($site_url);
96 | if (!is_array($parts) || !empty($parts['port']) || str_contains($parts['host'], 'localhost') || str_contains($parts['host'], 'local')) {
97 | return true;
98 | }
99 |
100 | // detect issues with WP Cron event not running
101 | // it should run every minute, so if it didn't run in 40 minutes there is most likely something wrong
102 | // some host run WP Cron only once per 15 minutes, so that is probably the lower bound of this check
103 | $next_scheduled = wp_next_scheduled('koko_analytics_aggregate_stats');
104 | if ($next_scheduled === false) {
105 | // if the event does not appear in scheduled event list at all
106 | // schedule it now
107 | wp_schedule_event(time() + 60, 'koko_analytics_stats_aggregate_interval', 'koko_analytics_aggregate_stats');
108 | return true;
109 | }
110 |
111 | return $next_scheduled !== false && $next_scheduled > (time() - 40 * 60);
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/tracking.php:
--------------------------------------------------------------------------------
1 | = esc_html__('Tracking settings', 'koko-analytics'); ?>
2 |
77 |
--------------------------------------------------------------------------------
/src/Resources/views/settings/jetpack_importer.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | []]); ?>
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
79 |
80 |
84 |
--------------------------------------------------------------------------------
/src/Import/Plausible_Importer.php:
--------------------------------------------------------------------------------
1 | = 3 && $header[0] == 'date' && $header[1] == 'visitors' && $header[2] == 'pageviews') {
41 | self::import_site_stats($fh, $header, $date_start, $date_end);
42 | } elseif (count($header) >= 4 && $header[0] == 'date' && $header[1] == 'hostname' && $header[2] == 'page' && $header[4] == 'visitors' && $header[5] == 'pageviews') {
43 | self::import_page_stats($fh, $header, $date_start, $date_end);
44 | } elseif (count($header) >= 3 && $header[0] == 'date' && $header[1] == 'source' && $header[2] == 'referrer') {
45 | self::import_referrer_stats($fh, $header, $date_start, $date_end);
46 | } else {
47 | throw new Exception("Sorry, that file is not supported.");
48 | }
49 | } catch (Exception $e) {
50 | static::redirect_with_error(static::get_admin_url(), $e->getMessage());
51 | }
52 |
53 | fclose($fh);
54 |
55 | static::redirect(static::get_admin_url(), ['success' => 1]);
56 | }
57 |
58 | private static function import_site_stats($fh, array $headers, string $date_start, string $date_end): void
59 | {
60 | /** @var wpdb $wpdb */
61 | global $wpdb;
62 |
63 | while ($row = fgetcsv($fh, 1024, ',', '"', '')) {
64 | $row = array_combine($headers, $row);
65 |
66 | // skip rows outside of date range
67 | if ($row['date'] < $date_start || $row['date'] > $date_end) {
68 | continue;
69 | }
70 |
71 | // update site stats
72 | $query = $wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_site_stats(date, visitors, pageviews) VALUES (%s, %d, %d) ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", [$row['date'], $row['visitors'], $row['pageviews']]);
73 | $wpdb->query($query);
74 | if ($wpdb->last_error !== '') {
75 | throw new Exception(__("A database error occurred: ", 'koko-analytics') . " {$wpdb->last_error}");
76 | }
77 | }
78 | }
79 |
80 | private static function import_page_stats($fh, array $headers, string $date_start, string $date_end): void
81 | {
82 | // "date","hostname","page","visits","visitors","pageviews","total_scroll_depth","total_scroll_depth_visits","total_time_on_page","total_time_on_page_visits"
83 |
84 | $rows = [];
85 | while ($row = fgetcsv($fh, 1024, ',', '"', '')) {
86 | $row = array_combine($headers, $row);
87 |
88 | // skip rows outside of date range
89 | if ($row['date'] < $date_start || $row['date'] > $date_end) {
90 | continue;
91 | }
92 |
93 | // add to rows
94 | $rows[] = [$row['date'], $row['page'], 0, $row['visitors'], $row['pageviews']];
95 |
96 | if (count($rows) >= 100) {
97 | static::bulk_insert_page_stats($rows);
98 | $rows = [];
99 | }
100 | }
101 |
102 | static::bulk_insert_page_stats($rows);
103 | }
104 |
105 | private static function import_referrer_stats($fh, array $headers, string $date_start, string $date_end): void
106 | {
107 | // "date","source","referrer","utm_source","utm_medium","utm_campaign","utm_content","utm_term","pageviews","visitors","visits","visit_duration","bounces"
108 |
109 | $rows = [];
110 | while ($row = fgetcsv($fh, 1024, ',', '"', '')) {
111 | $row = array_combine($headers, $row);
112 |
113 | // skip rows outside of date range
114 | if ($row['date'] < $date_start || $row['date'] > $date_end || empty($row['referrer'])) {
115 | continue;
116 | }
117 |
118 | // add to rows
119 | $rows[] = [$row['date'], $row['referrer'], $row['visitors'], $row['pageviews']];
120 |
121 | if (count($rows) >= 100) {
122 | static::bulk_insert_referrer_stats($rows);
123 | $rows = [];
124 | }
125 | }
126 |
127 | static::bulk_insert_referrer_stats($rows);
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/tests/Normalizers/NormalizerTest.php:
--------------------------------------------------------------------------------
1 | '',
17 | '/' => '/',
18 | '/about/' => '/about/',
19 | '/koko/is/great/' => '/koko/is/great/',
20 | '/?p=100' => '/?p=100',
21 | '/?utm_source=source&utm_medium=medium&utm_campaign=campaign' => '/',
22 | '/?utm_source=source&utm_medium=medium&p=200&utm_campaign=campaign' => '/?p=200',
23 | '/?attachment_id=123' => '/?attachment_id=123',
24 | '/blog/2024/01/hello-world/amp/' => '/blog/2024/01/hello-world/',
25 | ];
26 |
27 | foreach ($tests as $input => $output) {
28 | $this->assertEquals($output, Normalizer::path($input));
29 | }
30 | }
31 |
32 | public function test_referrer(): void
33 | {
34 | $tests = [
35 | '' => '',
36 | 'https://www.kokoanalytics.com' => 'kokoanalytics.com',
37 | 'https://dentalclinicwhatever.com' => 'dentalclinicwhatever.com',
38 | 'https://wordpress.org/plugins/koko-analytics/' => 'wordpress.org/plugins/koko-analytics/',
39 | 'https://pinterest.com/pin/foobar' => 'pinterest.com',
40 | 'https://www.pinterest.com' => 'pinterest.com',
41 | 'https://www.pinterest.com/pin/foobar' => 'pinterest.com',
42 | 'ios-app://429047995' => 'pinterest.com',
43 | 'https://www.google.com' => 'google.com',
44 | 'https://www.google.nl/url' => 'google.nl',
45 | 'https://www.google.nl/search' => 'google.nl',
46 | 'http://google.nl/search' => 'google.nl',
47 | 'https://www.google.co.uk/search' => 'google.co.uk',
48 | 'https://www.google.com/search' => 'google.com',
49 | 'android-app://com.google.android.googlequicksearchbox' => 'google.com',
50 | 'android-app://com.google.android.googlequicksearchbox/https/www.google.com' => 'google.com',
51 | 'android-app://com.www.google.android.googlequicksearchbox' => 'google.com',
52 | 'android-app://com.www.google.android.googlequicksearchbox/https/www.google.com' => 'google.com',
53 | 'android-app://com.www.google.android.gm' => 'google.com',
54 | 'https://bing.com' => 'bing.com',
55 | 'https://www.bing.com' => 'bing.com',
56 | 'https://www.bing.com/search' => 'bing.com',
57 | 'https://www.bing.com/url' => 'bing.com',
58 | 'android-app://com.facebook.katana' => 'facebook.com',
59 | 'android-app://m.facebook.com' => 'facebook.com',
60 | 'https://m.facebook.com' => 'facebook.com',
61 | 'https://m.facebook.com/profile/whatever' => 'facebook.com',
62 | 'https://l.facebook.com' => 'facebook.com',
63 | 'https://l.facebook.com/l.php' => 'facebook.com',
64 | 'https://lfacebook.com' => 'facebook.com', // Don't know what's up with this domain
65 | 'https://de-de.facebook.com' => 'facebook.com',
66 | 'https://www.facebook.com' => 'facebook.com',
67 | 'https://l.instagram.com' => 'instagram.com',
68 | 'https://instagram.com' => 'instagram.com',
69 | 'https://www.ecosia.org/search' => 'ecosia.org',
70 | 'https://www.linkedin.com/feed' => 'linkedin.com',
71 | 'https://www.linkedin.com/feed/' => 'linkedin.com',
72 | 'https://www.linkedin.com/feed/update/urn:li:activity:6620280880285921280' => 'linkedin.com',
73 | 'https://www.baidu.com/link' => 'baidu.com',
74 | 'https://m.baidu.com/from=844b/bd_page_type=1/ssid=98c26c6f6e676d65697869620b/uid=0/pu=usm%402%2Csz%40320_1001%2Cta%40iphone_2_9.0_24_79.0/baiduid=B24A174BB75A8A37CEA414106EC583CB/w=0_10_/t=iphone/l=1/tc' => 'baidu.com',
75 | 'https://yandex.ru/clck/jsredir' => 'yandex.ru',
76 | 'https://yandex.ru/search' => 'yandex.ru',
77 | 'https://search.yahoo.com/search;_ylt=AwrJ62D7vO9hhigARMpXNyoA;_ylu=Y29sbwNiZjEEcG9zAzEEdnRpZAMEc2VjA3BhZ2luYXRpb24-?p=danny+van+kooten' => 'search.yahoo.com',
78 | 'https://r.search.yahoo.com/search;_ylt=AwrJ62D7vO9hhigARMpXNyoA;_ylu=Y29sbwNiZjEEcG9zAzEEdnRpZAMEc2VjA3BhZ2luYXRpb24-?p=danny+van+kooten' => 'search.yahoo.com',
79 | 'https://r.search.yahoo.com/_ylt=AwrJ3s8QPIlhnGgADxAYAopQ;_ylu=c2VjA3NyBHNsawNpbWcEb2lkA2U2ZTY3ZmExZDUzNDAwYmU5MjAzYTYxN2U1ZTI5YTQ2BGdwb3MDMTcEaXQDYmluZw--/RV=2/RE=1636412560/RO=11/RU=http%3a%2f%2fvankootenarchitectuur.nl%2fverbouwing-kerkzaal-taborkerk%2f' => 'search.yahoo.com',
80 | 'https://out.reddit.com' => 'reddit.com',
81 | 'https://new.reddit.com' => 'reddit.com',
82 | 'https://old.reddit.com' => 'reddit.com',
83 | 'https://www.reddit.com' => 'reddit.com',
84 | 'https://m.reddit.com' => 'reddit.com',
85 | 'https://6gg78.r.ah.d.sendibm4.com/mk/cl/f/sugrxasd218e287' => 'brevo.com',
86 | 'https://6gg78.r.ah.d.sendibt1.com/mk/cl/f/sugrxasd218e287' => 'brevo.com',
87 |
88 | 'https://wordpress.org' => 'wordpress.org',
89 | 'https://wordpress.org/' => 'wordpress.org',
90 | 'https://wordpress.org/?utm_source=duckduckgo' => 'wordpress.org',
91 | 'https://wordpress.org/?page_id=500&utm_source=duckduckgo' => 'wordpress.org',
92 | 'https://wordpress.org/?utm_source=duckduckgo&p=500&cat=cars&product=toyota-yaris' => 'wordpress.org',
93 | 'https://wordpress.org/?foo=bar&p=500&utm_source=duckduckgo#utm_medium=link' => 'wordpress.org',
94 | 'https://wordpress.org/#foo=bar&bar=foo' => 'wordpress.org',
95 | ];
96 |
97 | foreach ($tests as $input => $output) {
98 | $this->assertEquals($output, Normalizer::referrer($input), $input);
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/Chart_View.php:
--------------------------------------------------------------------------------
1 | 0 ? 100.0 / (float) $n : 100.0;
17 | $y_max = 0;
18 | foreach ($data as $tick) {
19 | $y_max = max($y_max, $tick->pageviews);
20 | }
21 | $y_max_nice = $this->get_magnitude($y_max);
22 | $padding_top = 6;
23 | $padding_bottom = 24;
24 | $padding_left = 4 + strlen(number_format_i18n($y_max_nice)) * 8;
25 | $inner_height = $height - $padding_top - $padding_bottom;
26 | $height_modifier = $y_max_nice > 0 ? $inner_height / $y_max_nice : 1;
27 | $dateFormat = (string) get_option('date_format', 'Y-m-d');
28 | $daysDiff = abs($dateEnd->diff($dateStart)->days);
29 | $timezone = wp_timezone();
30 | ?>
31 |
32 | 7) { ?>
33 |
34 |
35 |
36 |
37 |
38 |
39 | 31) {
40 | ?>
42 |
43 |
44 |
45 |
46 | 0
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | getTimestamp()); ?>
55 | getTimestamp()); ?>
56 |
57 |
58 | date, $timezone));
60 | $is_weekend = (int) $dt->format('N') >= 6;
61 | $class_attr = $is_weekend ? 'class="weekend" ' : '';
62 | // data attributes are for the hover tooltip, which is handled in JS
63 | echo 'getTimestamp()), '" data-pageviews="', \number_format_i18n($tick->pageviews), '" data-visitors="', \number_format_i18n($tick->visitors),'">';
64 | echo ' ';
65 | echo ' ';
66 | echo ' ';
67 | echo ' ';
68 | } ?>
69 |
70 |
71 |
87 |
100000) {
97 | return (int) ceil($n / 10000.0) * 10000;
98 | }
99 |
100 | $e = floor(log10($n));
101 | $pow = pow(10, $e);
102 | return (int) (ceil($n / $pow) * $pow);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------