Plugin Directory

Changeset 3755014


Ignore:
Timestamp:
10/19/2026 02:48:37 PM (3 weeks ago)
Author:
paintops
Message:

1.2.081

Location:
paintops/trunk
Files:
1 added
15 edited

Legend:

Unmodified
Added
Removed
  • paintops/trunk/assets/js/paintops-sw-src.js

    r3747168 r3755014  
    1616var PAGE_CACHE = 'paintops-pages-' + VERSION;
    1717var ASSET_CACHE = 'paintops-assets-' + VERSION;
     18/* Photos, receipts and documents served through the permission-checked
     19 * routes, kept apart from the public assets so that signing out can drop
     20 * them without throwing away the shell (1.2.081). */
     21var PRIVATE_CACHE = 'paintops-private-' + VERSION;
    1822
    1923/* On install: cache the icons (must succeed) and opportunistically the
    … …  
    4953      if ((k.indexOf('paintops-shell-') === 0 && k !== SHELL_CACHE) ||
    5054          (k.indexOf('paintops-assets-') === 0 && k !== ASSET_CACHE) ||
     55          (k.indexOf('paintops-private-') === 0 && k !== PRIVATE_CACHE) ||
    5156          (k.indexOf('paintops-pages-') === 0 && k !== PAGE_CACHE)) {
    5257        return caches.delete(k);
    … …  
    6368  /* Logout housekeeping: the page posts this when it renders the
    6469   * "please log in" view, so a shared device does not keep one user's
    65    * cached job pages after they sign out. */
     70   * cached job pages, or their photos and receipts, after they sign out. */
    6671  if (data.type === 'paintops-clear-pages') {
    67     event.waitUntil(caches.delete(PAGE_CACHE));
     72    event.waitUntil(Promise.all([caches.delete(PAGE_CACHE), caches.delete(PRIVATE_CACHE)]));
    6873    return;
    6974  }
    … …  
    7883    event.waitUntil((async function () {
    7984      var cache = await caches.open(PAGE_CACHE);
     85      var priv = await caches.open(PRIVATE_CACHE);
    8086      await Promise.all(data.urls.map(async function (u) {
    8187        try {
    8288          var r = await fetch(u, { credentials: 'same-origin' });
    83           if (r && r.ok && r.type === 'basic') { await cache.put(u, r.clone()); }
     89          if (!(r && r.ok && r.type === 'basic')) { return; }
     90          await cache.put(u, r.clone());
     91          /* The page's photo thumbnails come with it (1.2.081): a job
     92           * saved for the day is no use in a basement without its photos,
     93           * and photos are served through a route the worker only sees
     94           * when the browser asks. Up to forty per page, in the private
     95           * cache that sign-out clears. */
     96          var html = await r.text();
     97          var re = /admin-post\.php\?action=paintops_photo(?:&amp;|&)[^"'\s<>]*?size=thumb[^"'\s<>]*/g;
     98          var seen = {}; var m; var n = 0;
     99          while ((m = re.exec(html)) !== null && n < 40) {
     100            var p = m[0].replace(/&amp;/g, '&').replace(/&#038;/g, '&');
     101            var abs; try { abs = new URL(p, u).href; } catch (e2) { continue; }
     102            if (seen[abs]) { continue; }
     103            seen[abs] = true; n++;
     104            try {
     105              if (await priv.match(abs)) { continue; }
     106              var pr = await fetch(abs, { credentials: 'same-origin' });
     107              if (pr && pr.ok) { await priv.put(abs, pr); }
     108            } catch (e3) { /* skip this one */ }
     109          }
    84110        } catch (e) { /* offline or a bad URL, skip it */ }
    85111      }));
    … …  
    92118}
    93119
    94 /* A receipt image or a supporting document, served through the
    95  * capability-checked route rather than from the uploads folder. Matched
    96  * on the exact action names so nothing else under admin-post.php is
    97  * touched. The nonce in the query string is part of the cache key, which
    98  * is fine: a stale key simply misses and re-fetches. */
     120/* A job photo, a receipt image or a supporting document, served through
     121 * the capability-checked routes rather than from the uploads folder.
     122 * Matched on the exact action names so nothing else under admin-post.php
     123 * is touched. A receipt or document link carries a nonce that is part of
     124 * the cache key, which is fine: a stale key simply misses and re-fetches.
     125 * A photo link has no nonce (1.2.081), so the same photo is one entry. */
    99126function isPrivateFileRequest(url) {
    100127  if (url.pathname.indexOf('admin-post.php') === -1) { return false; }
    101128  var action = url.searchParams ? url.searchParams.get('action') : '';
    102   return action === 'paintops_receipt' || action === 'paintops_evidence';
     129  return action === 'paintops_photo' || action === 'paintops_receipt' || action === 'paintops_evidence';
    103130}
    104131
    … …  
    222249 * refresh it, so an updated asset heals itself on the next load without
    223250 * waiting for a version bump. With no cached copy yet, wait on network. */
    224 async function handleAssetRequest(request, event) {
    225   var cache = await caches.open(ASSET_CACHE);
     251async function handleAssetRequest(request, event, cacheName) {
     252  var cache = await caches.open(cacheName || ASSET_CACHE);
    226253  var cached = await cache.match(request);
    227254  var networkPromise = fetch(request).then(function (res) {
    … …  
    245272  if (url.origin !== self.location.origin) { return; }
    246273
    247   // RECEIPTS AND THEIR DOCUMENTS ARE THE ONE EXCEPTION TO THE wp-admin
    248   // RULE BELOW (2026-09-24). They used to be ordinary /wp-content/uploads
    249   // image URLs, so isStaticAsset() matched them and a painter could open
    250   // a receipt on a job with no signal. Now that they are served through a
    251   // permission-checked route on admin-post.php, the blanket wp-admin
    252   // bypass would drop them out of the cache and take that away.
     274  // PHOTOS, RECEIPTS AND THEIR DOCUMENTS ARE THE ONE EXCEPTION TO THE
     275  // wp-admin RULE BELOW (receipts 2026-09-24, job photos 1.2.081). They
     276  // used to be ordinary /wp-content/uploads image URLs, so
     277  // isStaticAsset() matched them and a painter could open a job's photos
     278  // with no signal. Now that they are served through a permission-checked
     279  // route on admin-post.php, the blanket wp-admin bypass would drop them
     280  // out of the cache and take that away.
    253281  //
    254282  // What is cached here is a file this person was already allowed to see,
    … …  
    258286  // is versioned with the plugin, so it clears on every update.
    259287  if (isPrivateFileRequest(url)) {
    260     event.respondWith(handleAssetRequest(request, event));
     288    event.respondWith(handleAssetRequest(request, event, PRIVATE_CACHE));
    261289    return;
    262290  }
  • paintops/trunk/assets/js/pwa-app.js

    r3747168 r3755014  
    3131        });
    3232
    33       if (document.querySelector('.paintops-login-required')) {
    34         // Signed out on a shared device: clear cached job pages.
     33      // THE SIGN-IN BOX ONLY (data-po-signed-out), not every box that
     34      // shares its class: "You do not have access to this job" and the
     35      // vehicle log's refusal use the same styling for a signed-in painter,
     36      // and clearing the saved pages and photos on those would strip the
     37      // phone of its offline copies over a reassigned job (review of
     38      // 1.2.081).
     39      if (document.querySelector('.paintops-login-required[data-po-signed-out]')) {
     40        // Signed out on a shared device: clear cached job pages and the
     41        // photos and receipts saved with them.
    3542        navigator.serviceWorker.ready.then(function (reg) {
    3643          if (reg && reg.active) {
    … …  
    6774            var n = r.result || 0;
    6875            if (!n) { return; }
    69             var box = document.querySelector('.paintops-login-required');
     76            var box = document.querySelector('.paintops-login-required[data-po-signed-out]');
    7077            if (!box || box.querySelector('.paintops-tc-queued-note')) { return; }
    7178            var p = document.createElement('p');
  • paintops/trunk/changelog.txt

    r3747168 r3755014  
    44
    55== Changelog ==
     6
     7= 1.2.081 =
     8* Changed: job photos now live in a private folder on your server instead of the WordPress media library, the same way receipts have since 1.2.058. A photo opens for you and for the painters on that job, and for nobody else; the media library sat at a web address that worked for anyone who had it. Each photo keeps a small copy beside it for the gallery, the Before/After pairs and the job page, so those load no slower than before. A photo you put on an invoice still shows to the customer holding that invoice link. Checkup shows a card with the number of photos and receipts still in the media library and a button that moves them in batches of 100; everything keeps working while that runs, a photo your website also uses in a page or gallery is copied and left in the media library as well, and a file that is not on the server right now is left alone and counted rather than written off. Photos from the AI Visualizer are kept the same way. The painter's phone now saves the photos of the day's jobs along with the job pages, so they are there with no signal even for a job that was never opened, and on a shared phone signing out clears the photos and receipts the app had saved.
     9* Changed: the "Review on Capterra" button on Checkup opens Capterra's own write-a-review form.
    610
    711= 1.2.080 =
  • paintops/trunk/includes/checkup-review.php

    r3733666 r3755014  
    4848        'review'   => 'https://wordpress.org/support/plugin/paintops/reviews/#new-post',
    4949        'register' => 'https://login.wordpress.org/register',
    50         // Capterra listing, live 2026-10-07 (Mike). The product page; Capterra's
    51         // own "Write a Review" form sits behind a per-product UUID that only
    52         // the vendor portal shows, so swap this for that URL when it is known.
    53         'capterra' => 'https://www.capterra.com/p/10186495/PaintClarity/',
     50        // Capterra's own "Write a Review" form for the PaintClarity listing
     51        // (vendor-portal URL, Mike 2026-10-17; the listing itself is
     52        // https://www.capterra.com/p/10186495/PaintClarity/ and was the
     53        // button's target from 1.2.079 until this one was known).
     54        'capterra' => 'https://reviews.capterra.com/products/new/284c5b7b-1c06-4716-9507-998121438b4e/',
    5455    ];
    5556    /**
  • paintops/trunk/includes/color-visualizer.php

    r3725547 r3755014  
    274274                            <?php foreach ($po_vis_photos as $p) : ?>
    275275                                <button type="button" class="po-aiv-thumb" data-photo-id="<?php echo esc_attr((string) $p['id']); ?>" data-url="<?php echo esc_url($p['full_url']); ?>" data-caption="<?php echo esc_attr($p['caption']); ?>" title="<?php echo esc_attr($p['caption']); ?>" aria-label="<?php echo esc_attr('Use this job photo: ' . $p['caption']); ?>">
    276                                     <img src="<?php echo esc_url($p['url']); ?>" alt="">
     276                                    <img loading="lazy" decoding="async" src="<?php echo esc_url($p['url']); ?>" alt="">
    277277                                    <span class="po-aiv-thumb-cap"><?php echo esc_html($p['caption']); ?></span>
    278278                                </button>
  • paintops/trunk/includes/estimates.php

    r3747168 r3755014  
    10281028                ?>
    10291029                <button type="button" class="ai-existing-thumb" data-photo-id="<?php echo esc_attr($pid); ?>" onclick="paintopsAiToggleExisting(this)" style="position:relative;width:84px;height:84px;padding:0;border:2px solid #ddd;border-radius:8px;overflow:hidden;cursor:pointer;background:#fff;">
    1030                     <img src="<?php echo esc_url($thumb); ?>" alt="Job photo" style="width:100%;height:100%;object-fit:cover;display:block;">
     1030                    <img loading="lazy" decoding="async" src="<?php echo esc_url($thumb); ?>" alt="Job photo" style="width:100%;height:100%;object-fit:cover;display:block;">
    10311031                    <span class="ai-thumb-check" style="display:none;position:absolute;top:3px;right:3px;width:20px;height:20px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;line-height:20px;text-align:center;">&#10003;</span>
    10321032                </button>
  • paintops/trunk/includes/job-invoices.php

    r3747168 r3755014  
    207207    foreach ( $photo_ids as $pid ) {
    208208        if ( isset( $rows_by_id[$pid] ) && ($rows_by_id[$pid]['url'] ?? '') !== '' ) {
    209             $sel_photos[] = $rows_by_id[$pid];
     209            $item = $rows_by_id[$pid];
     210            // A photo in private storage opens only for a signed-in viewer
     211            // on the job. The customer reading this invoice is signed out
     212            // and holds the invoice token, so the public document carries
     213            // links that name the invoice; the photo route lets a photo
     214            // this invoice lists through on that token (1.2.081,
     215            // includes/photo-storage.php).
     216            if ( $public && !empty( $item['private'] ) && function_exists( 'paintops_photo_public_url' ) ) {
     217                $item['url'] = paintops_photo_public_url( $pid, $invoice, 'thumb' );
     218                $item['full_url'] = paintops_photo_public_url( $pid, $invoice, 'full' );
     219            }
     220            $sel_photos[] = $item;
    210221        }
    211222    }
  • paintops/trunk/includes/job-photos.php

    r3747168 r3755014  
    482482
    483483        // TWO KINDS OF ROW LIVE HERE AT ONCE, and this is the one place
    484         // that has to know it. A job PHOTO is a media-library attachment
    485         // and always will be. A RECEIPT is a file in private storage,
    486         // unless it was filed before that shipped and has not been
    487         // migrated yet, in which case it is still an attachment. See
    488         // includes/receipt-storage.php.
     484        // that has to know it. A row filed since private storage shipped
     485        // (receipts 1.2.058, photos 1.2.081) is a file in the private
     486        // folder; a row from before that has not been migrated yet is
     487        // still a media-library attachment. A receipt comes back through
     488        // the nonced receipt route (includes/receipt-storage.php), a photo
     489        // through the photo route with its thumbnail
     490        // (includes/photo-storage.php).
    489491        if ($private_file !== '') {
    490             $view = paintops_receipt_view('job', (int) $row['id'], $private_file, 0);
    491             $row['url']      = $view['url'];
    492             $row['full_url'] = $view['url'];
    493             $row['is_pdf']   = $view['is_pdf'];
     492            if ((string) $row['context'] === 'receipt') {
     493                $view = paintops_receipt_view('job', (int) $row['id'], $private_file, 0);
     494                $row['url']      = $view['url'];
     495                $row['full_url'] = $view['url'];
     496                $row['is_pdf']   = $view['is_pdf'];
     497            } else {
     498                $view = paintops_photo_view((int) $row['id']);
     499                $row['url']      = $view['url'];
     500                $row['full_url'] = $view['full_url'];
     501                $row['is_pdf']   = false;
     502            }
    494503        } else {
    495504            $thumb = wp_get_attachment_image_url($attachment_id, 'medium');
    … …  
    582591    $table = paintops_table('job_photos');
    583592    $rows = $wpdb->get_results($wpdb->prepare(
    584         "SELECT id, job_id, expense_id, context, attachment_id, uploaded_by, uploaded_at, notes, taken_at, gps_lat, gps_lng, photo_role, paired_photo_id
     593        "SELECT id, job_id, expense_id, context, attachment_id, private_file, uploaded_by, uploaded_at, notes, taken_at, gps_lat, gps_lng, photo_role, paired_photo_id
    585594         FROM %i
    586595         WHERE job_id = %d AND context IN ('note','photo')
    … …  
    589598    foreach ($rows as &$row) {
    590599        $attachment_id = (int) $row['attachment_id'];
    591         $thumb = $attachment_id > 0 ? wp_get_attachment_image_url($attachment_id, 'medium') : false;
    592         $full  = $attachment_id > 0 ? wp_get_attachment_image_url($attachment_id, 'full') : false;
    593         $row['url']      = $thumb ?: ($full ?: '');
    594         $row['full_url'] = $full ?: $row['url'];
     600        $private_file  = trim((string) ($row['private_file'] ?? ''));
     601        if ($private_file !== '') {
     602            // A photo in private storage (1.2.081); see paintops_get_job_photos().
     603            $view = paintops_photo_view((int) $row['id']);
     604            $row['url']      = $view['url'];
     605            $row['full_url'] = $view['full_url'];
     606        } else {
     607            $thumb = $attachment_id > 0 ? wp_get_attachment_image_url($attachment_id, 'medium') : false;
     608            $full  = $attachment_id > 0 ? wp_get_attachment_image_url($attachment_id, 'full') : false;
     609            $row['url']      = $thumb ?: ($full ?: '');
     610            $row['full_url'] = $full ?: $row['url'];
     611        }
     612        $row['private'] = ($private_file !== '');
    595613
    596614        $uploader = (int) $row['uploaded_by'] > 0 ? get_userdata((int) $row['uploaded_by']) : false;
    … …  
    754772                <?php if ($hasPhoto): ?>
    755773                <div class="thumb">
    756                     <a href="<?php echo esc_url($item['full_url']); ?>" target="_blank" rel="noopener noreferrer"><img src="<?php echo esc_url($item['url']); ?>" alt="Job photo"></a>
     774                    <a href="<?php echo esc_url($item['full_url']); ?>" target="_blank" rel="noopener noreferrer"><img loading="lazy" decoding="async" src="<?php echo esc_url($item['url']); ?>" alt="Job photo"></a>
    757775                </div>
    758776                <?php endif; ?>
    … …  
    10501068
    10511069/**
     1070 * intermediate_image_sizes_advanced filter callback, keeps only
     1071 * 'medium', dropping thumbnail/medium_large/large/theme-custom sizes.
     1072 * Job photos and receipts no longer become attachments at all (private
     1073 * storage, 1.2.058 and 1.2.081); this is kept for the one attachment the
     1074 * plugin still makes, the expense report PDF's page images
     1075 * (pro-expense-reports.php).
     1076 */
     1077function paintops_restrict_photo_image_sizes(array $sizes): array {
     1078    return array_intersect_key($sizes, ['medium' => true]);
     1079}
     1080
     1081/**
    10521082 * Handle one uploaded file (a single entry from $_FILES) for a job:
    1053  * resize/compress it, register it as a normal WordPress media
    1054  * attachment (so it lives in the Media Library, survives backups, and
    1055  * works with any offload-to-S3 plugin), and record it in cs_job_photos.
     1083 * resize/compress it, move it into private storage with a thumbnail
     1084 * beside it (includes/photo-storage.php; receipts through
     1085 * includes/receipt-storage.php), and record it in cs_job_photos.
    10561086 *
    10571087 * Returns the new row's ID on success, or a WP_Error explaining what
    … …  
    10601090 * the limit is reached, that's trivially bypassed by a raw POST).
    10611091 */
    1062 
    1063 /**
    1064  * intermediate_image_sizes_advanced filter callback, keeps only
    1065  * 'medium', dropping thumbnail/medium_large/large/theme-custom sizes.
    1066  * See the doc comment at its add_filter() call site in
    1067  * paintops_handle_job_photo_upload() for why.
    1068  */
    1069 function paintops_restrict_photo_image_sizes(array $sizes): array {
    1070     return array_intersect_key($sizes, ['medium' => true]);
    1071 }
    10721092
    10731093function paintops_handle_job_photo_upload(int $job_id, array $file, string $context = 'photo', ?int $expense_id = null, string $notes = '', string $photo_role = '', ?int $pair_with_before_id = null) {
    … …  
    11051125    // Core admin includes loaded the documented way for front-end media
    11061126    // handling (the permitted require_once exception): wp_handle_upload()
    1107     // below comes from file.php, and wp_insert_attachment's metadata step
    1108     // further down (wp_generate_attachment_metadata) comes from image.php.
     1127    // below comes from file.php; image.php for the editor it may need.
    11091128    // Used immediately after loading, never called directly over HTTP.
    11101129    require_once ABSPATH . 'wp-admin/includes/image.php';
    … …  
    11301149            : 'Only image files can be uploaded here.');
    11311150    }
     1151    // Private storage keeps a short list of types it will serve (the
     1152    // ones every phone produces and every browser shows). Anything else
     1153    // is refused here, with the list, rather than at the adopt step
     1154    // below with a message that names no types.
     1155    $upload_ext = strtolower((string) pathinfo((string) $upload['file'], PATHINFO_EXTENSION));
     1156    if (!$is_pdf_receipt && !isset(paintops_private_allowed_types()[$upload_ext])) {
     1157        wp_delete_file($upload['file']);
     1158        return new WP_Error('paintops_not_an_image', ($context === 'receipt' ? 'Receipts' : 'Photos') . ' can be a JPG, PNG, GIF, WEBP or HEIC' . ($context === 'receipt' ? ', or a PDF.' : '.'));
     1159    }
    11321160
    11331161    // Read whatever EXIF the photo already had BEFORE the resize/
    … …  
    11431171    }
    11441172
    1145     // A RECEIPT NEVER BECOMES AN ATTACHMENT (2026-09-24). Everything in
    1146     // the media library sits at a public URL, and a receipt is a
    1147     // purchase record: vendor, amount, date, often a card's last four.
    1148     // Job PHOTOS still go to the media library, because a photo of a
    1149     // finished fence is the thing the owner wants to text to a customer
    1150     // and nothing on it is private.
     1173    // NEITHER A RECEIPT NOR A PHOTO BECOMES AN ATTACHMENT. Everything in
     1174    // the media library sits at a public URL. A receipt is a purchase
     1175    // record (vendor, amount, date, often a card's last four; 2026-09-24)
     1176    // and a job photo is the inside of somebody's house with the address
     1177    // on the job (decision 18 of the 2026-10-13 audit; 1.2.081).
    11511178    //
    11521179    // The file has already been resized and had its EXIF read above, so
    11531180    // this just moves it out of uploads/ and into the private folder
    1154     // under a generated name. See includes/receipt-storage.php.
     1181    // under a generated name. A photo gets a thumbnail beside it, since
     1182    // the gallery, the Before/After pairs and the job page show it
     1183    // small; a receipt is served at the size it was stored. See
     1184    // includes/receipt-storage.php and includes/photo-storage.php.
    11551185    $private_file  = '';
    11561186    $attachment_id = 0;
    … …  
    11641194        $private_file = $adopted[0];
    11651195    } else {
    1166         $attachment_id = wp_insert_attachment([
    1167             'post_mime_type' => $upload['type'],
    1168             'post_title'     => sanitize_file_name(basename($upload['file'])),
    1169             'post_content'   => '',
    1170             'post_status'    => 'inherit',
    1171         ], $upload['file']);
    1172 
    1173         if (!$attachment_id || is_wp_error($attachment_id)) {
    1174             return new WP_Error('paintops_attach_failed', 'Could not save the uploaded photo.');
    1175         }
    1176     }
    1177 
    1178     // Left to its defaults, wp_generate_attachment_metadata() generates
    1179     // every image size WordPress core AND the active theme have
    1180     // registered, thumbnail, medium, medium_large, large, whatever
    1181     // custom sizes a theme adds, for every single photo/receipt.
    1182     // paintops_get_job_photos() only ever reads 'medium' (grid thumbnail)
    1183     // and 'full' (the original file itself, already capped to ~1600px by
    1184     // paintops_resize_and_compress_image() above, not a generated size,
    1185     // so it exists regardless of this filter). Everything else was pure
    1186     // wasted disk space that multiplies with every photo on every job
    1187     // (one upload was producing 6 files on disk). Scoped narrowly to
    1188     // just this one call, so the rest of the site's media library,
    1189     // blog post images uploaded the normal way, etc., keeps its usual
    1190     // theme-registered sizes.
    1191     // Only an attachment has generated sizes. A private receipt is stored
    1192     // once, at the size the resize above left it, which is already
    1193     // bounded to roughly 1600px; nothing asks it for a 'medium'.
    1194     if (!$is_pdf_receipt && $attachment_id > 0) {
    1195         add_filter('intermediate_image_sizes_advanced', 'paintops_restrict_photo_image_sizes');
    1196         $attach_data = wp_generate_attachment_metadata($attachment_id, $upload['file']);
    1197         remove_filter('intermediate_image_sizes_advanced', 'paintops_restrict_photo_image_sizes');
    1198         wp_update_attachment_metadata($attachment_id, $attach_data);
     1196        $stored = paintops_photo_store_file((string) $upload['file']);
     1197        if (is_wp_error($stored)) {
     1198            wp_delete_file($upload['file']);
     1199            return new WP_Error('paintops_attach_failed', $stored->get_error_message());
     1200        }
     1201        $private_file = $stored;
    11991202    }
    12001203
    … …  
    15461549        }
    15471550
    1548         return '<div class="paintops-notice is-success">Photo uploaded (attachment/photo row #' . esc_html((string) $result) . ').</div>';
     1551        return '<div class="paintops-notice is-success">Photo uploaded.</div>';
    15491552    }
    15501553
    … …  
    17571760                    <div style="width:170px;">
    17581761                        <a href="<?php echo esc_url($photo['full_url']); ?>" target="_blank" rel="noopener noreferrer">
    1759                             <img src="<?php echo esc_url($photo['url']); ?>" alt="Job photo" style="width:170px;height:170px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
     1762                            <img loading="lazy" decoding="async" src="<?php echo esc_url($photo['url']); ?>" alt="Job photo" style="width:170px;height:170px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
    17601763                        </a>
    17611764
    … …  
    18601863                    <div style="width:220px;">
    18611864                        <div style="display:flex;gap:4px;">
    1862                             <img src="<?php echo esc_url($before['url']); ?>" alt="Before" style="width:108px;height:108px;object-fit:cover;border-radius:6px 0 0 6px;border:1px solid #ddd;display:block;">
    1863                             <img src="<?php echo esc_url($after['url']); ?>" alt="After" style="width:108px;height:108px;object-fit:cover;border-radius:0 6px 6px 0;border:1px solid #ddd;display:block;">
     1865                            <img loading="lazy" decoding="async" src="<?php echo esc_url($before['url']); ?>" alt="Before" style="width:108px;height:108px;object-fit:cover;border-radius:6px 0 0 6px;border:1px solid #ddd;display:block;">
     1866                            <img loading="lazy" decoding="async" src="<?php echo esc_url($after['url']); ?>" alt="After" style="width:108px;height:108px;object-fit:cover;border-radius:0 6px 6px 0;border:1px solid #ddd;display:block;">
    18641867                        </div>
    18651868                        <?php paintops_render_compare_trigger_button($before['full_url'], $after['full_url']); ?>
    … …  
    18921895                    <?php foreach ($unpairedBefores as $waitingIndex => $before): ?>
    18931896                        <div class="pp-ba-waiting-card<?php echo esc_attr($waitingIndex === $lastWaitingIndex ? ' pp-ba-just-added' : ''); ?>" style="width:140px;">
    1894                             <img src="<?php echo esc_url($before['url']); ?>" alt="Before" style="width:140px;height:140px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
     1897                            <img loading="lazy" decoding="async" src="<?php echo esc_url($before['url']); ?>" alt="Before" style="width:140px;height:140px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
    18951898                            <?php paintops_render_before_after_capture_button((int) $before['id'], $before['full_url']); ?>
    18961899                        </div>
    … …  
    19591962                        return '<label class="po-pair-tile" for="' . esc_attr($id) . '" title="' . esc_attr($cap) . '">'
    19601963                            . '<input type="radio" name="pair_' . esc_attr($side) . '_photo_id" id="' . esc_attr($id) . '" value="' . esc_attr((string) $photo['id']) . '">'
    1961                             . '<img src="' . esc_url((string) ($photo['url'] ?? '')) . '" alt="">'
     1964                            . '<img loading="lazy" decoding="async" src="' . esc_url((string) ($photo['url'] ?? '')) . '" alt="">'
    19621965                            . '<span class="po-pair-cap">' . esc_html($cap) . '</span>'
    19631966                            . '</label>';
    … …  
    24552458                <div style="width:100px;">
    24562459                    <a href="<?php echo esc_url($photo['full_url']); ?>" target="_blank" rel="noopener noreferrer">
    2457                         <img src="<?php echo esc_url($photo['url']); ?>" alt="Job photo" title="<?php echo esc_attr($hoverTitle); ?>" style="width:100px;height:100px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
     2460                        <?php // Eager on purpose: the job page is what a painter opens before driving into a basement, and the worker can only keep a photo the browser asked for. ?>
     2461                        <img decoding="async" src="<?php echo esc_url($photo['url']); ?>" alt="Job photo" title="<?php echo esc_attr($hoverTitle); ?>" style="width:100px;height:100px;object-fit:cover;border-radius:6px;border:1px solid #ddd;display:block;">
    24582462                    </a>
    24592463                    <?php if (!empty($photo['notes'])): ?>
  • paintops/trunk/includes/manage-jobs.php

    r3747168 r3755014  
    15441544        return function_exists('paintops_login_required_box')
    15451545            ? paintops_login_required_box()
    1546             : '<div class="paintops-login-required"><p>Please log in to access PaintClarity.</p></div>';
     1546            : '<div class="paintops-login-required" data-po-signed-out="1"><p>Please log in to access PaintClarity.</p></div>';
    15471547    }
    15481548
  • paintops/trunk/includes/private-files.php

    r3710549 r3755014  
    5555}
    5656
    57 /** The types safe to show in the browser rather than hand over as a download. */
     57/**
     58 * The types safe to show in the browser rather than hand over as a
     59 * download. HEIC is included (1.2.081): a browser that can show it does,
     60 * one that cannot shows nothing, and until now tapping a HEIC job photo
     61 * opened it in Safari rather than downloading it.
     62 */
    5863function paintops_private_inline_types(): array {
    59     return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'];
     64    return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'pdf'];
    6065}
    6166
    … …  
    259264 * here. Two callers legitimately have a real file and no upload:
    260265 *
    261  *   1. the job photo handler, which runs wp_handle_upload() first (it
    262  *      reads EXIF and resizes the file in place before storing it), so
    263  *      by the time it decides where the file belongs it has been moved
    264  *      out of the temp directory already;
     266 *   1. the job photo and receipt handler, which runs wp_handle_upload()
     267 *      first (it reads EXIF and resizes the file in place before storing
     268 *      it), so by the time it decides where the file belongs it has been
     269 *      moved out of the temp directory already; the AI Visualizer's
     270 *      "keep this render" does the same with its own file;
    265271 *   2. the migration, which is moving files that have been sitting in
    266272 *      the media library for months.
  • paintops/trunk/includes/receipt-storage.php

    r3747168 r3755014  
    1616 * So receipts now live in the same private directory as everything else
    1717 * PaintClarity keeps to itself (see includes/private-files.php) and come
    18  * back out through ONE route that checks who is asking.
     18 * back out through ONE route that checks who is asking. Job PHOTOS
     19 * followed in 1.2.081 (includes/photo-storage.php, its own route with a
     20 * wider rule); the migration card and batch below move both.
    1921 *
    2022 * ---------------------------------------------------------------------
    … …  
    171173function paintops_receipt_delete(string $stored_file, int $attachment_id): void {
    172174    if ($stored_file !== '') {
    173         paintops_private_delete_file($stored_file);
     175        // A job photo has a thumbnail beside it (includes/photo-storage.php);
     176        // a receipt has none, and deleting a name that is not there is a no-op.
     177        if (function_exists('paintops_photo_delete_files')) {
     178            paintops_photo_delete_files($stored_file);
     179        } else {
     180            paintops_private_delete_file($stored_file);
     181        }
    174182    }
    175183    if ($attachment_id > 0) {
    … …  
    484492function paintops_receipt_migration_counts(): array {
    485493    global $wpdb;
    486     $out = ['job' => 0, 'business' => 0, 'vehicle' => 0, 'total' => 0];
     494    $out = ['job' => 0, 'business' => 0, 'vehicle' => 0, 'photo' => 0, 'total' => 0];
    487495
    488496    foreach (paintops_receipt_kinds() as $kind => $cfg) {
    … …  
    494502        $args  = [$t, $cfg['att_col'], $cfg['file_col'], $cfg['file_col'], ''];
    495503        if ($kind === 'job') {
    496             // Job PHOTOS stay in the media library on purpose. Only
    497             // context='receipt' rows are moving.
     504            // The receipt rows of the shared table; the photo rows are
     505            // counted below, on their own line of the card.
    498506            $where .= ' AND %i = %s';
    499507            array_push($args, 'context', 'receipt');
    … …  
    502510        $out[$kind] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE {$where}", $args));
    503511        $out['total'] += $out[$kind];
     512    }
     513
     514    // JOB PHOTOS (1.2.081, includes/photo-storage.php): every non-receipt
     515    // row that still points at a media-library attachment.
     516    if (paintops_receipt_columns_ready('job')) {
     517        $out['photo'] = (int) $wpdb->get_var($wpdb->prepare(
     518            "SELECT COUNT(*) FROM %i WHERE attachment_id > 0 AND (private_file IS NULL OR private_file = '') AND context <> 'receipt'",
     519            paintops_table('job_photos')
     520        ));
     521        $out['total'] += $out['photo'];
    504522    }
    505523    return $out;
    … …  
    528546 * again.
    529547 *
    530  * Returns ['moved' => int, 'failed' => int, 'remaining' => int].
     548 * Returns ['moved', 'kept', 'missing', 'failed', 'remaining'] (ints):
     549 * kept = copied, but the attachment stays in the media library because
     550 * the website uses it (photos only); missing = the file is not on the
     551 * server right now and the row was left alone; failed = nothing changed.
     552 * A batch also stops after about twenty seconds of work, so a slow host
     553 * never runs into its own execution limit part way through a row.
    531554 */
    532555function paintops_receipt_migrate_batch(int $limit = 100): array {
    533556    global $wpdb;
    534557
    535     $limit  = max(1, min(500, $limit));
    536     $moved  = 0;
    537     $failed = 0;
     558    $limit   = max(1, min(500, $limit));
     559    $moved   = 0;
     560    $kept    = 0;
     561    $missing = 0;
     562    $failed  = 0;
     563    $started = microtime(true);
     564    $budget  = (float) apply_filters('paintops_migration_batch_seconds', 20.0);
     565    $out_of_time = static function () use ($started, $budget): bool {
     566        return (microtime(true) - $started) > $budget;
     567    };
     568
     569    // Rows are walked by id with a cursor, a page at a time, so a run of
     570    // rows that are skipped (missing files) at the low end does not hide
     571    // the movable rows behind them on every batch (review of 1.2.081).
     572    // $reached_end is true when every kind has been walked to its last
     573    // row; only then can "nothing but missing files" be said of the site.
     574    $reached_end = true;
     575    $done_work   = static function () use (&$moved, &$kept, &$failed): int {
     576        return $moved + $kept + $failed;
     577    };
    538578
    539579    foreach (paintops_receipt_kinds() as $kind => $cfg) {
    540         if ($moved + $failed >= $limit) {
     580        if ($done_work() >= $limit) {
     581            $reached_end = false;
    541582            break;
    542583        }
    … …  
    545586        }
    546587        $t     = paintops_table($cfg['table']);
    547         $take  = $limit - ($moved + $failed);
    548         $where = '%i > 0 AND (%i IS NULL OR %i = %s)';
    549         $args  = [$t, $cfg['att_col'], $cfg['file_col'], $cfg['file_col'], ''];
    550         if ($kind === 'job') {
    551             $where .= ' AND %i = %s';
    552             array_push($args, 'context', 'receipt');
    553         }
    554         array_push($args, 'id', $take);
    555         // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- the only interpolation is the literal clause assembled directly above; every identifier and value is bound
    556         $rows = $wpdb->get_results($wpdb->prepare(
    557             "SELECT id, {$cfg['att_col']} AS att FROM %i WHERE {$where} ORDER BY %i ASC LIMIT %d",
    558             $args
    559         ));
     588        $after = 0;
     589        while ($done_work() < $limit) {
     590            if ($out_of_time()) {
     591                $reached_end = false;
     592                break 2;
     593            }
     594            $take  = $limit - $done_work();
     595            $where = '%i > 0 AND (%i IS NULL OR %i = %s) AND %i > %d';
     596            $args  = [$t, $cfg['att_col'], $cfg['file_col'], $cfg['file_col'], '', 'id', $after];
     597            if ($kind === 'job') {
     598                $where .= ' AND %i = %s';
     599                array_push($args, 'context', 'receipt');
     600            }
     601            array_push($args, 'id', $take);
     602            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- the only interpolation is the literal clause assembled directly above; every identifier and value is bound
     603            $rows = $wpdb->get_results($wpdb->prepare(
     604                "SELECT id, {$cfg['att_col']} AS att FROM %i WHERE {$where} ORDER BY %i ASC LIMIT %d",
     605                $args
     606            ));
     607            if (!$rows) {
     608                break; // this kind is walked to the end
     609            }
    560610
    561611        foreach ((array) $rows as $row) {
    562             $att  = (int) $row->att;
     612            $after = max($after, (int) $row->id);
     613            if ($out_of_time()) {
     614                $reached_end = false;
     615                break 3;
     616            }
     617            $att = (int) $row->att;
     618
     619            if ($att > 0 && get_post($att) === null) {
     620                // The attachment itself was deleted from the Media Library:
     621                // nothing can come back, so the row stops pointing at it.
     622                $wpdb->update($t, [$cfg['att_col'] => null], ['id' => (int) $row->id], ['%d'], ['%d']);
     623                $moved++;
     624                continue;
     625            }
    563626            $path = $att > 0 ? (string) get_attached_file($att) : '';
    564627
    565628            if ($path === '' || !is_file($path)) {
    566                 // The attachment row exists but its file is gone, which
    567                 // happens when someone tidied the uploads folder by
    568                 // hand. There is nothing to move, and leaving the row
    569                 // pointing at a dead attachment would make it come back
    570                 // in every future batch forever. Clear it instead and
    571                 // count it as moved: the receipt was already lost, and
    572                 // now the record says so.
    573                 $wpdb->update($t, [$cfg['att_col'] => null], ['id' => (int) $row->id], ['%d'], ['%d']);
    574                 if ($att > 0) {
    575                     wp_delete_attachment($att, true);
    576                 }
    577                 $moved++;
     629                // The file is not on the server right now. Until 1.2.081
     630                // this cleared the row and deleted the attachment on the
     631                // reasoning that the receipt was already lost; on a site
     632                // whose uploads are offloaded, being restored from a
     633                // backup, or on a mount that is briefly away, that turned
     634                // every receipt into a row pointing at nothing and
     635                // reported success. Now the row and the attachment are
     636                // left exactly as they are and counted so the card can say
     637                // so (review of 1.2.081).
     638                $missing++;
    578639                continue;
    579640            }
    … …  
    608669            $moved++;
    609670        }
     671        }
     672    }
     673
     674    // JOB PHOTOS, with whatever is left of the batch (1.2.081). The same
     675    // copy, point, delete order, plus the thumbnail; see
     676    // paintops_photo_migrate_row() in includes/photo-storage.php.
     677    if ($done_work() < $limit && $reached_end && function_exists('paintops_photo_migrate_row') && paintops_receipt_columns_ready('job')) {
     678        $after = 0;
     679        while ($done_work() < $limit) {
     680            if ($out_of_time()) {
     681                $reached_end = false;
     682                break;
     683            }
     684            $rows = $wpdb->get_results($wpdb->prepare(
     685                "SELECT id, attachment_id AS att FROM %i WHERE attachment_id > 0 AND (private_file IS NULL OR private_file = '') AND context <> 'receipt' AND id > %d ORDER BY id ASC LIMIT %d",
     686                paintops_table('job_photos'), $after, $limit - $done_work()
     687            ));
     688            if (!$rows) {
     689                break;
     690            }
     691            foreach ((array) $rows as $row) {
     692                $after = max($after, (int) $row->id);
     693                if ($out_of_time()) {
     694                    $reached_end = false;
     695                    break 2;
     696                }
     697                $res = paintops_photo_migrate_row((int) $row->id, (int) $row->att);
     698                if ($res === 'failed') {
     699                    $failed++;
     700                } elseif ($res === 'missing') {
     701                    $missing++;
     702                } elseif ($res === 'kept') {
     703                    $kept++;
     704                } else {
     705                    $moved++;
     706                }
     707            }
     708        }
     709    } elseif ($done_work() >= $limit) {
     710        $reached_end = false;
    610711    }
    611712
    612713    $counts = paintops_receipt_migration_counts();
    613     return ['moved' => $moved, 'failed' => $failed, 'remaining' => (int) $counts['total']];
     714    return ['moved' => $moved, 'kept' => $kept, 'missing' => $missing, 'failed' => $failed, 'remaining' => (int) $counts['total'], 'reached_end' => $reached_end];
    614715}
    615716
    … …  
    629730    }
    630731
    631     $n     = (int) $counts['total'];
    632     $done  = (int) get_option('paintops_receipts_moved_total', 0);
    633     $fails = (int) get_option('paintops_receipts_move_failed', 0);
     732    $n       = (int) $counts['total'];
     733    $photos  = (int) ($counts['photo'] ?? 0);
     734    $rcpts   = $n - $photos;
     735    $done    = (int) get_option('paintops_receipts_moved_total', 0);
     736    $fails   = (int) get_option('paintops_receipts_move_failed', 0);
     737    $kept    = (int) get_option('paintops_photos_kept_public', 0);
     738    // The last batch found nothing it could do (every row left is a file
     739    // that is not on the server right now): say so instead of offering a
     740    // button that would say the same thing again. The option holds the
     741    // count at that moment; a count that has changed since means there
     742    // is something new to try.
     743    $stalled = ((int) get_option('paintops_files_move_stalled', 0) === $n);
     744    $parts   = [];
     745    if ($photos > 0) {
     746        $parts[] = $photos === 1 ? 'one job photo' : number_format_i18n($photos) . ' job photos';
     747    }
     748    if ($rcpts > 0) {
     749        $parts[] = $rcpts === 1 ? 'one receipt image' : number_format_i18n($rcpts) . ' receipt images';
     750    }
     751    $what = implode(' and ', $parts);
    634752
    635753    ob_start();
    636754    ?>
    637755    <div class="cs-financial-box po-receipt-move">
    638         <h3 style="margin-top:0;">Move receipts into private storage</h3>
     756        <h3 style="margin-top:0;">Move <?php echo esc_html($photos > 0 && $rcpts > 0 ? 'photos and receipts' : ($photos > 0 ? 'photos' : 'receipts')); ?> into private storage</h3>
    639757        <p class="description" style="margin-top:0;">
    640             <?php echo esc_html($n === 1
    641                 ? 'One receipt image is still stored in your WordPress media library.'
    642                 : number_format_i18n($n) . ' receipt images are still stored in your WordPress media library.'); ?>
     758            <?php echo esc_html(ucfirst($what) . ($n === 1 ? ' is' : ' are') . ' still stored in your WordPress media library.'); ?>
    643759            Anything in the media library sits at a web address that works for anyone who has it, signed in or not.
    644             Moving them puts each one behind the same permission check as the rest of PaintClarity, so a receipt
    645             opens for you and your crew and for nobody else.
     760            Moving them puts each one behind the same permission check as the rest of PaintClarity<?php
     761                echo $photos > 0 ? ': a job photo opens for you and the crew on that job' : '';
     762                echo $rcpts > 0 ? ($photos > 0 ? ', a receipt' : ': a receipt') . ' for you and the person who filed it' : '';
     763            ?>, and for nobody else.
    646764        </p>
    647765        <p class="description">
    648766            This runs in batches so it never ties up your site. You can stop after any batch and pick it up later.
    649             Receipts you have already moved keep working the whole time.
     767            Everything you have already moved keeps working the whole time<?php echo $photos > 0 ? ', and a photo on an invoice still shows to the customer holding that invoice link' : ''; ?>.
     768            <?php if ($photos > 0) : ?>A photo that your website also uses (in a page or a gallery) is copied and left in the media library as well, so the website keeps it.<?php endif; ?>
    650769        </p>
    651         <?php if ($done > 0) : ?>
     770        <?php if ($done > 0 || $kept > 0 || $fails > 0) : ?>
    652771            <p class="description"><strong translate="no" class="notranslate"><?php echo esc_html(number_format_i18n($done)); ?></strong> moved so far<?php
     772                echo $kept > 0 ? ', ' . esc_html(number_format_i18n($kept)) . ' copied and also left in the media library because your website uses them' : '';
    653773                echo $fails > 0 ? ', ' . esc_html(number_format_i18n($fails)) . ' could not be moved and were left alone' : ''; ?>.</p>
    654774        <?php endif; ?>
     775        <?php if ($stalled) : ?>
     776            <p class="description"><strong>Nothing more can be moved right now.</strong>
     777                <?php echo esc_html(($n === 1 ? 'The file for the remaining one is' : 'The files for the remaining ' . number_format_i18n($n) . ' are') . ' not on the server at the moment (an uploads folder that is offloaded, being restored, or missing). They were left exactly as they are; if the files come back, this card will offer the button again.'); ?>
     778            </p>
     779        <?php else : ?>
    655780        <form method="post" style="margin:12px 0 0;">
    656781            <?php paintops_nonce_field('paintops_move_receipts', 'paintops_move_receipts_nonce'); ?>
    … …  
    659784            </button>
    660785        </form>
     786        <?php endif; ?>
    661787    </div>
    662788    <?php
    … …  
    682808    }
    683809    if (!function_exists('paintops_user_can_view_financials') || !paintops_user_can_view_financials()) {
    684         $done = '<div class="paintops-notice is-error">You do not have permission to move receipts.</div>';
     810        $done = '<div class="paintops-notice is-error">You do not have permission to move files.</div>';
    685811        return $done;
    686812    }
    … …  
    688814    $res = paintops_receipt_migrate_batch(100);
    689815
    690     update_option('paintops_receipts_moved_total', (int) get_option('paintops_receipts_moved_total', 0) + (int) $res['moved'], false);
    691     if ((int) $res['failed'] > 0) {
    692         update_option('paintops_receipts_move_failed', (int) $res['failed'], false);
    693     }
    694 
    695     if ((int) $res['moved'] === 0 && (int) $res['failed'] === 0) {
     816    $did = (int) $res['moved'] + (int) $res['kept'];
     817    update_option('paintops_receipts_moved_total', (int) get_option('paintops_receipts_moved_total', 0) + $did, false);
     818    if ((int) $res['kept'] > 0) {
     819        update_option('paintops_photos_kept_public', (int) get_option('paintops_photos_kept_public', 0) + (int) $res['kept'], false);
     820    }
     821    // The failure count is this batch's, not a running total: a clean
     822    // batch clears it, so the card does not repeat an old number forever.
     823    update_option('paintops_receipts_move_failed', (int) $res['failed'], false);
     824    // Every row left is a missing file: remember the count so the card
     825    // stops offering the button until that count changes.
     826    update_option('paintops_files_move_stalled', ($did === 0 && (int) $res['failed'] === 0 && (int) $res['missing'] > 0 && !empty($res['reached_end'])) ? (int) $res['remaining'] : 0, false);
     827
     828    if ($did === 0 && (int) $res['failed'] === 0 && (int) $res['missing'] === 0) {
    696829        $done = '<div class="paintops-notice is-success">There was nothing left to move.</div>';
    697830        return $done;
    698831    }
    699 
    700     $msg = number_format_i18n((int) $res['moved']) . ' receipt' . ((int) $res['moved'] === 1 ? '' : 's') . ' moved into private storage.';
     832    if ($did === 0 && (int) $res['failed'] === 0) {
     833        $done = '<div class="paintops-notice is-warning">' . esc_html(number_format_i18n((int) $res['missing']) . ' file' . ((int) $res['missing'] === 1 ? ' is' : 's are') . ' not on the server right now, so nothing was moved. They were left exactly as they are.' . (empty($res['reached_end']) ? ' Press the button again to carry on past them.' : '')) . '</div>';
     834        return $done;
     835    }
     836
     837    $msg = number_format_i18n((int) $res['moved']) . ' file' . ((int) $res['moved'] === 1 ? '' : 's') . ' moved into private storage.';
     838    if ((int) $res['kept'] > 0) {
     839        $msg .= ' ' . number_format_i18n((int) $res['kept']) . ' ' . ((int) $res['kept'] === 1 ? 'photo was' : 'photos were') . ' copied and also left in the media library because your website uses ' . ((int) $res['kept'] === 1 ? 'it' : 'them') . '.';
     840    }
    701841    if ((int) $res['remaining'] > 0) {
    702842        $msg .= ' ' . number_format_i18n((int) $res['remaining']) . ' still to go, press the button again to continue.';
    703843    } else {
    704844        $msg .= ' That was the last of them.';
     845    }
     846    if ((int) $res['missing'] > 0) {
     847        $msg .= ' ' . number_format_i18n((int) $res['missing']) . ' ' . ((int) $res['missing'] === 1 ? 'is' : 'are') . ' not on the server right now and ' . ((int) $res['missing'] === 1 ? 'was' : 'were') . ' left alone.';
    705848    }
    706849    if ((int) $res['failed'] > 0) {
    … …  
    711854    }
    712855
    713     $done = '<div class="paintops-notice ' . ((int) $res['failed'] > 0 ? 'is-warning' : 'is-success') . '">' . esc_html($msg) . '</div>';
     856    $done = '<div class="paintops-notice ' . (((int) $res['failed'] > 0 || (int) $res['missing'] > 0) ? 'is-warning' : 'is-success') . '">' . esc_html($msg) . '</div>';
    714857    return $done;
    715858}
  • paintops/trunk/includes/schema.php

    r3747168 r3755014  
    476476
    477477    // Job photos and expense receipts, both are just images attached to
    478     // a job, stored as real WordPress media attachments (so they show up
    479     // in the Media Library, survive backups, and work with any offload-
    480     // to-S3 plugin) with this table only tracking which job/expense each
     478    // a job. Since 1.2.058 (receipts) and 1.2.081 (photos) the file is in
     479    // the plugin's private folder under the generated name in
     480    // private_file, served through a permission-checked route; rows from
     481    // before are media-library attachments (attachment_id) until the
     482    // Checkup card moves them. This table tracks which job/expense each
    481483    // one belongs to and which of the two buckets it's in. "context"
    482484    // separates general job photos (before/after, progress shots, an
    … …  
    15751577        'photo_role'      => $wpdb->prepare("ALTER TABLE %i ADD COLUMN photo_role VARCHAR(10) NULL", $photos),
    15761578        'paired_photo_id' => $wpdb->prepare("ALTER TABLE %i ADD COLUMN paired_photo_id BIGINT UNSIGNED NULL", $photos),
    1577         // PRIVATE RECEIPT STORAGE (2026-09-24). A receipt row carries
    1578         // either this file name or the old attachment_id, never both
    1579         // once it has been moved. See includes/receipt-storage.php and
    1580         // the batched migration on the Checkup screen. Job PHOTOS are
    1581         // untouched and keep using attachment_id.
     1579        // PRIVATE STORAGE (receipts 2026-09-24, job photos 1.2.081). A
     1580        // row carries either this file name or the old attachment_id,
     1581        // never both once it has been moved. See includes/receipt-storage.php,
     1582        // includes/photo-storage.php and the batched migration on the
     1583        // Checkup screen.
    15821584        'private_file'    => $wpdb->prepare("ALTER TABLE %i ADD COLUMN private_file VARCHAR(190) NULL", $photos),
    15831585    ];
  • paintops/trunk/includes/timeoff.php

    r3694342 r3755014  
    2323// including it. Kept here too as defense in depth in case that changes.
    2424if (!paintops_user_can_manage()) {
    25     echo '<div class="paintops-login-required"><p>Please log in to access PaintClarity.</p></div>';
     25    echo '<div class="paintops-login-required" data-po-signed-out="1"><p>Please log in to access PaintClarity.</p></div>';
    2626    return;
    2727}
  • paintops/trunk/paintops.php

    r3747168 r3755014  
    55 * Plugin URI: https://paintclarity.com
    66 * Description: Estimating, scheduling, job tracking, crews, invoicing, and profit reporting for painting contractors.
    7  * Version: 1.2.080
     7 * Version: 1.2.081
    88 * Author: PaintClarity
    99 * Author URI: https://paintclarity.com/the-paintclarity-story/
    … …  
    167167        }
    168168    }
    169     define( 'PAINTOPS_VERSION', '1.2.080' );
     169    define( 'PAINTOPS_VERSION', '1.2.081' );
    170170    define( 'PAINTOPS_PLUGIN_FILE', __FILE__ );
    171171    define( 'PAINTOPS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    … …  
    49494949     *  per PaintClarity table plus a settings.json and a README. This is an
    49504950     *  export for the accountant / a spare off-site copy / moving sites,
    4951      *  NOT a full backup: NO FILE of any kind is included. Photos and
    4952      *  receipt images live in the media library; supporting documents live
    4953      *  in uploads/paintops-private/ and are listed in expense_files.csv
    4954      *  without their contents. The README and the Business-page card say so
     4951     *  NOT a full backup: NO FILE of any kind is included. Photos, receipt
     4952     *  images and supporting documents live in uploads/paintops-private/
     4953     *  (older ones still in the media library until the Checkup card has
     4954     *  moved them); the documents are listed in expense_files.csv without
     4955     *  their contents. The README and the Business-page card say so
    49554956     *  and point to a real full-site backup guide. No import/restore is
    49564957     *  offered yet. Secrets (API keys, tokens) are never exported.
    … …  
    57095710        }
    57105711        $paintops_login_url = wp_login_url( $paintops_back );
    5711         return '<div class="paintops-login-required" style="max-width:420px;margin:48px auto;padding:28px 24px;border:1px solid #dfe5ec;border-radius:12px;background:#fff;color:#1f2d3d;text-align:center;box-shadow:0 2px 10px rgba(20,40,70,0.06);">' . '<p style="margin:0 0 6px;font-size:17px;font-weight:700;color:#1f2d3d;">Please log in to access PaintClarity</p>' . '<p style="margin:0 0 18px;font-size:14px;color:#556677;">Sign in with your team username and password. You will land right back where you were headed.</p>' . '<p style="margin:0;"><a href="' . esc_url( $paintops_login_url ) . '" style="display:inline-block;background:#2f5aae;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 28px;border-radius:8px;">Log In</a></p>' . '</div>';
     5712        return '<div class="paintops-login-required" data-po-signed-out="1" style="max-width:420px;margin:48px auto;padding:28px 24px;border:1px solid #dfe5ec;border-radius:12px;background:#fff;color:#1f2d3d;text-align:center;box-shadow:0 2px 10px rgba(20,40,70,0.06);">' . '<p style="margin:0 0 6px;font-size:17px;font-weight:700;color:#1f2d3d;">Please log in to access PaintClarity</p>' . '<p style="margin:0 0 18px;font-size:14px;color:#556677;">Sign in with your team username and password. You will land right back where you were headed.</p>' . '<p style="margin:0;"><a href="' . esc_url( $paintops_login_url ) . '" style="display:inline-block;background:#2f5aae;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 28px;border-radius:8px;">Log In</a></p>' . '</div>';
    57125713    }
    57135714
    … …  
    77927793        require_once PAINTOPS_PLUGIN_DIR . 'includes/receipt-evidence.php';
    77937794        require_once PAINTOPS_PLUGIN_DIR . 'includes/receipt-storage.php';
     7795        // Job photos in the same private folder, their own route and the
     7796        // thumbnail beside each one (1.2.081). After receipt-storage.php,
     7797        // whose migration batch and delete helper call into it.
     7798        require_once PAINTOPS_PLUGIN_DIR . 'includes/photo-storage.php';
    77947799        // Review ask card at the foot of Checkup (free, administrators only).
    77957800        require_once PAINTOPS_PLUGIN_DIR . 'includes/checkup-review.php';
  • paintops/trunk/readme.txt

    r3747168 r3755014  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.2.080
     7Stable tag: 1.2.081
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    112112
    113113= What happens to my data if I deactivate or delete the plugin? =
    114 Nothing is deleted. Your jobs, quotes, customers, payments, hours, and photos stay in your WordPress database, so nothing is lost if you deactivate temporarily or reinstall later. If you truly want the data gone, delete the plugin's tables and its uploads from your database and media library.
     114Nothing is deleted. Your jobs, quotes, customers, payments, hours, and photos stay in your WordPress database, so nothing is lost if you deactivate temporarily or reinstall later. If you truly want the data gone, delete the plugin's tables from your database and the paintops-private folder from your uploads.
    115115
    116116= Does it work without OpenAI, Stripe, or Google Maps? =
    … …  
    153153
    154154== Changelog ==
     155= 1.2.081 =
     156* Changed: job photos now live in a private folder on your server instead of the WordPress media library, the same way receipts have since 1.2.058. A photo opens for you and for the painters on that job, and for nobody else; the media library sat at a web address that worked for anyone who had it. Each photo keeps a small copy beside it for the gallery, the Before/After pairs and the job page, so those load no slower than before. A photo you put on an invoice still shows to the customer holding that invoice link. Checkup shows a card with the number of photos and receipts still in the media library and a button that moves them in batches of 100; everything keeps working while that runs, a photo your website also uses in a page or gallery is copied and left in the media library as well, and a file that is not on the server right now is left alone and counted rather than written off. Photos from the AI Visualizer are kept the same way. The painter's phone now saves the photos of the day's jobs along with the job pages, so they are there with no signal even for a job that was never opened, and on a shared phone signing out clears the photos and receipts the app had saved.
     157* Changed: the "Review on Capterra" button on Checkup opens Capterra's own write-a-review form.
     158
    155159= 1.2.080 =
    156160* Changed: office access is now administrators only. Any WordPress user who could edit posts (Author, Contributor, Editor) used to be full PaintClarity staff with every job, customer, lead and note. Painters and helpers keep their own role and screens. A site that used an Editor account for the office needs to make that person an administrator.
    … …  
    181185* Added: Checkup tells administrators when the site is not on HTTPS, lists the features that depend on it (home-screen install, offline mode, online payments, encryption) and what to do. The same check has lived in Settings > Setup Health since July.
    182186
    183 = 1.2.078 =
    184 * Added: a review card at the foot of Checkup, for administrators, with a link to leave an honest WordPress.org review and the two steps to get a free WordPress.org account. It appears once the site has completed three jobs or used PaintClarity for a month; "I already reviewed" hides it for good, "Maybe later" for sixty days. Reviews are never paid or rewarded.
    185 * Changed: the link at the foot of the menu that read "WP Admin" is now "Settings" and opens PaintClarity > Settings. Administrators only, as before.
    186 * Added: Help now opens with "Watch the walkthrough", a link to the five-minute video tour in the Help Center, with chapter links to jump to any part of the app.
    187 * Fixed: the Jobs rows keep their layout when the browser translates the page. The Actions toggle used to grow across the Job Value figure, crew initials were translated and spilled out of their circles, and the "Job Value" header, the section counts and "Tomorrow" stayed English. The toggle is now capped to its column, initials and crew names are left alone, and the labels, counts and "Tomorrow" translate. Lead rows pin the contact and the received date the same way.
    188 * Fixed: on Production Rates, "Add area" on a completed job that has no paintable area now opens a one-field Save form right in the row, the same form the completion confirmation uses, and the job's rate appears as soon as it is saved. It used to send you to Edit Job.
    189 * Fixed: "Add hours" on Production Rates now opens the job's Hours page, where hours are entered. It used to open Job Pulse, which only shows them.
    190 * Fixed: new jobs, quotes and invoices are stamped with the site's own date and time. They used to take the database server's clock (UTC on most hosts), so on a US site a quote or invoice created in the evening carried the next day's date, and an invoice's balance could read low for a few hours after another invoice on the same job was marked paid. Records created before this update keep their original stamp.
    191 
    192187The full history is in changelog.txt in the plugin folder.
Note: See TracChangeset for help on using the changeset viewer.