Changeset 3755014
- Timestamp:
- 10/19/2026 02:48:37 PM (3 weeks ago)
- Location:
- paintops/trunk
- Files:
-
- 1 added
- 15 edited
-
assets/js/paintops-sw-src.js (modified) (8 diffs)
-
assets/js/pwa-app.js (modified) (2 diffs)
-
changelog.txt (modified) (1 diff)
-
includes/checkup-review.php (modified) (1 diff)
-
includes/color-visualizer.php (modified) (1 diff)
-
includes/estimates.php (modified) (1 diff)
-
includes/job-invoices.php (modified) (1 diff)
-
includes/job-photos.php (modified) (16 diffs)
-
includes/manage-jobs.php (modified) (1 diff)
-
includes/photo-storage.php (added)
-
includes/private-files.php (modified) (2 diffs)
-
includes/receipt-storage.php (modified) (13 diffs)
-
includes/schema.php (modified) (2 diffs)
-
includes/timeoff.php (modified) (1 diff)
-
paintops.php (modified) (5 diffs)
-
readme.txt (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
paintops/trunk/assets/js/paintops-sw-src.js
r3747168 r3755014 16 16 var PAGE_CACHE = 'paintops-pages-' + VERSION; 17 17 var 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). */ 21 var PRIVATE_CACHE = 'paintops-private-' + VERSION; 18 22 19 23 /* On install: cache the icons (must succeed) and opportunistically the … … 49 53 if ((k.indexOf('paintops-shell-') === 0 && k !== SHELL_CACHE) || 50 54 (k.indexOf('paintops-assets-') === 0 && k !== ASSET_CACHE) || 55 (k.indexOf('paintops-private-') === 0 && k !== PRIVATE_CACHE) || 51 56 (k.indexOf('paintops-pages-') === 0 && k !== PAGE_CACHE)) { 52 57 return caches.delete(k); … … 63 68 /* Logout housekeeping: the page posts this when it renders the 64 69 * "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. */ 66 71 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)])); 68 73 return; 69 74 } … … 78 83 event.waitUntil((async function () { 79 84 var cache = await caches.open(PAGE_CACHE); 85 var priv = await caches.open(PRIVATE_CACHE); 80 86 await Promise.all(data.urls.map(async function (u) { 81 87 try { 82 88 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(?:&|&)[^"'\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(/&/g, '&').replace(/&/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 } 84 110 } catch (e) { /* offline or a bad URL, skip it */ } 85 111 })); … … 92 118 } 93 119 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. */ 99 126 function isPrivateFileRequest(url) { 100 127 if (url.pathname.indexOf('admin-post.php') === -1) { return false; } 101 128 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'; 103 130 } 104 131 … … 222 249 * refresh it, so an updated asset heals itself on the next load without 223 250 * 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);251 async function handleAssetRequest(request, event, cacheName) { 252 var cache = await caches.open(cacheName || ASSET_CACHE); 226 253 var cached = await cache.match(request); 227 254 var networkPromise = fetch(request).then(function (res) { … … 245 272 if (url.origin !== self.location.origin) { return; } 246 273 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. 253 281 // 254 282 // What is cached here is a file this person was already allowed to see, … … 258 286 // is versioned with the plugin, so it clears on every update. 259 287 if (isPrivateFileRequest(url)) { 260 event.respondWith(handleAssetRequest(request, event ));288 event.respondWith(handleAssetRequest(request, event, PRIVATE_CACHE)); 261 289 return; 262 290 } -
paintops/trunk/assets/js/pwa-app.js
r3747168 r3755014 31 31 }); 32 32 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. 35 42 navigator.serviceWorker.ready.then(function (reg) { 36 43 if (reg && reg.active) { … … 67 74 var n = r.result || 0; 68 75 if (!n) { return; } 69 var box = document.querySelector('.paintops-login-required ');76 var box = document.querySelector('.paintops-login-required[data-po-signed-out]'); 70 77 if (!box || box.querySelector('.paintops-tc-queued-note')) { return; } 71 78 var p = document.createElement('p'); -
paintops/trunk/changelog.txt
r3747168 r3755014 4 4 5 5 == 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. 6 10 7 11 = 1.2.080 = -
paintops/trunk/includes/checkup-review.php
r3733666 r3755014 48 48 'review' => 'https://wordpress.org/support/plugin/paintops/reviews/#new-post', 49 49 '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/', 54 55 ]; 55 56 /** -
paintops/trunk/includes/color-visualizer.php
r3725547 r3755014 274 274 <?php foreach ($po_vis_photos as $p) : ?> 275 275 <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=""> 277 277 <span class="po-aiv-thumb-cap"><?php echo esc_html($p['caption']); ?></span> 278 278 </button> -
paintops/trunk/includes/estimates.php
r3747168 r3755014 1028 1028 ?> 1029 1029 <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;"> 1031 1031 <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;">✓</span> 1032 1032 </button> -
paintops/trunk/includes/job-invoices.php
r3747168 r3755014 207 207 foreach ( $photo_ids as $pid ) { 208 208 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; 210 221 } 211 222 } -
paintops/trunk/includes/job-photos.php
r3747168 r3755014 482 482 483 483 // 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). 489 491 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 } 494 503 } else { 495 504 $thumb = wp_get_attachment_image_url($attachment_id, 'medium'); … … 582 591 $table = paintops_table('job_photos'); 583 592 $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_id593 "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 585 594 FROM %i 586 595 WHERE job_id = %d AND context IN ('note','photo') … … 589 598 foreach ($rows as &$row) { 590 599 $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 !== ''); 595 613 596 614 $uploader = (int) $row['uploaded_by'] > 0 ? get_userdata((int) $row['uploaded_by']) : false; … … 754 772 <?php if ($hasPhoto): ?> 755 773 <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> 757 775 </div> 758 776 <?php endif; ?> … … 1050 1068 1051 1069 /** 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 */ 1077 function paintops_restrict_photo_image_sizes(array $sizes): array { 1078 return array_intersect_key($sizes, ['medium' => true]); 1079 } 1080 1081 /** 1052 1082 * Handle one uploaded file (a single entry from $_FILES) for a job: 1053 * resize/compress it, register it as a normal WordPress media1054 * attachment (so it lives in the Media Library, survives backups, and1055 * 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. 1056 1086 * 1057 1087 * Returns the new row's ID on success, or a WP_Error explaining what … … 1060 1090 * the limit is reached, that's trivially bypassed by a raw POST). 1061 1091 */ 1062 1063 /**1064 * intermediate_image_sizes_advanced filter callback, keeps only1065 * 'medium', dropping thumbnail/medium_large/large/theme-custom sizes.1066 * See the doc comment at its add_filter() call site in1067 * 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 }1072 1092 1073 1093 function 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) { … … 1105 1125 // Core admin includes loaded the documented way for front-end media 1106 1126 // 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. 1109 1128 // Used immediately after loading, never called directly over HTTP. 1110 1129 require_once ABSPATH . 'wp-admin/includes/image.php'; … … 1130 1149 : 'Only image files can be uploaded here.'); 1131 1150 } 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 } 1132 1160 1133 1161 // Read whatever EXIF the photo already had BEFORE the resize/ … … 1143 1171 } 1144 1172 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). 1151 1178 // 1152 1179 // The file has already been resized and had its EXIF read above, so 1153 1180 // 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. 1155 1185 $private_file = ''; 1156 1186 $attachment_id = 0; … … 1164 1194 $private_file = $adopted[0]; 1165 1195 } 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; 1199 1202 } 1200 1203 … … 1546 1549 } 1547 1550 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>'; 1549 1552 } 1550 1553 … … 1757 1760 <div style="width:170px;"> 1758 1761 <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;"> 1760 1763 </a> 1761 1764 … … 1860 1863 <div style="width:220px;"> 1861 1864 <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;"> 1864 1867 </div> 1865 1868 <?php paintops_render_compare_trigger_button($before['full_url'], $after['full_url']); ?> … … 1892 1895 <?php foreach ($unpairedBefores as $waitingIndex => $before): ?> 1893 1896 <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;"> 1895 1898 <?php paintops_render_before_after_capture_button((int) $before['id'], $before['full_url']); ?> 1896 1899 </div> … … 1959 1962 return '<label class="po-pair-tile" for="' . esc_attr($id) . '" title="' . esc_attr($cap) . '">' 1960 1963 . '<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="">' 1962 1965 . '<span class="po-pair-cap">' . esc_html($cap) . '</span>' 1963 1966 . '</label>'; … … 2455 2458 <div style="width:100px;"> 2456 2459 <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;"> 2458 2462 </a> 2459 2463 <?php if (!empty($photo['notes'])): ?> -
paintops/trunk/includes/manage-jobs.php
r3747168 r3755014 1544 1544 return function_exists('paintops_login_required_box') 1545 1545 ? 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>'; 1547 1547 } 1548 1548 -
paintops/trunk/includes/private-files.php
r3710549 r3755014 55 55 } 56 56 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 */ 58 63 function paintops_private_inline_types(): array { 59 return ['jpg', 'jpeg', 'png', 'gif', 'webp', ' pdf'];64 return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'pdf']; 60 65 } 61 66 … … 259 264 * here. Two callers legitimately have a real file and no upload: 260 265 * 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; 265 271 * 2. the migration, which is moving files that have been sitting in 266 272 * the media library for months. -
paintops/trunk/includes/receipt-storage.php
r3747168 r3755014 16 16 * So receipts now live in the same private directory as everything else 17 17 * 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. 19 21 * 20 22 * --------------------------------------------------------------------- … … 171 173 function paintops_receipt_delete(string $stored_file, int $attachment_id): void { 172 174 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 } 174 182 } 175 183 if ($attachment_id > 0) { … … 484 492 function paintops_receipt_migration_counts(): array { 485 493 global $wpdb; 486 $out = ['job' => 0, 'business' => 0, 'vehicle' => 0, ' total' => 0];494 $out = ['job' => 0, 'business' => 0, 'vehicle' => 0, 'photo' => 0, 'total' => 0]; 487 495 488 496 foreach (paintops_receipt_kinds() as $kind => $cfg) { … … 494 502 $args = [$t, $cfg['att_col'], $cfg['file_col'], $cfg['file_col'], '']; 495 503 if ($kind === 'job') { 496 // Job PHOTOS stay in the media library on purpose. Only497 // co ntext='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. 498 506 $where .= ' AND %i = %s'; 499 507 array_push($args, 'context', 'receipt'); … … 502 510 $out[$kind] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE {$where}", $args)); 503 511 $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']; 504 522 } 505 523 return $out; … … 528 546 * again. 529 547 * 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. 531 554 */ 532 555 function paintops_receipt_migrate_batch(int $limit = 100): array { 533 556 global $wpdb; 534 557 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 }; 538 578 539 579 foreach (paintops_receipt_kinds() as $kind => $cfg) { 540 if ($moved + $failed >= $limit) { 580 if ($done_work() >= $limit) { 581 $reached_end = false; 541 582 break; 542 583 } … … 545 586 } 546 587 $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 } 560 610 561 611 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 } 563 626 $path = $att > 0 ? (string) get_attached_file($att) : ''; 564 627 565 628 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++; 578 639 continue; 579 640 } … … 608 669 $moved++; 609 670 } 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; 610 711 } 611 712 612 713 $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]; 614 715 } 615 716 … … 629 730 } 630 731 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); 634 752 635 753 ob_start(); 636 754 ?> 637 755 <div class="cs-financial-box po-receipt-move"> 638 <h3 style="margin-top:0;">Move receiptsinto 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> 639 757 <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.'); ?> 643 759 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. 646 764 </p> 647 765 <p class="description"> 648 766 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; ?> 650 769 </p> 651 <?php if ($done > 0 ) : ?>770 <?php if ($done > 0 || $kept > 0 || $fails > 0) : ?> 652 771 <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' : ''; 653 773 echo $fails > 0 ? ', ' . esc_html(number_format_i18n($fails)) . ' could not be moved and were left alone' : ''; ?>.</p> 654 774 <?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 : ?> 655 780 <form method="post" style="margin:12px 0 0;"> 656 781 <?php paintops_nonce_field('paintops_move_receipts', 'paintops_move_receipts_nonce'); ?> … … 659 784 </button> 660 785 </form> 786 <?php endif; ?> 661 787 </div> 662 788 <?php … … 682 808 } 683 809 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>'; 685 811 return $done; 686 812 } … … 688 814 $res = paintops_receipt_migrate_batch(100); 689 815 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) { 696 829 $done = '<div class="paintops-notice is-success">There was nothing left to move.</div>'; 697 830 return $done; 698 831 } 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 } 701 841 if ((int) $res['remaining'] > 0) { 702 842 $msg .= ' ' . number_format_i18n((int) $res['remaining']) . ' still to go, press the button again to continue.'; 703 843 } else { 704 844 $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.'; 705 848 } 706 849 if ((int) $res['failed'] > 0) { … … 711 854 } 712 855 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>'; 714 857 return $done; 715 858 } -
paintops/trunk/includes/schema.php
r3747168 r3755014 476 476 477 477 // 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 481 483 // one belongs to and which of the two buckets it's in. "context" 482 484 // separates general job photos (before/after, progress shots, an … … 1575 1577 'photo_role' => $wpdb->prepare("ALTER TABLE %i ADD COLUMN photo_role VARCHAR(10) NULL", $photos), 1576 1578 '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 carries1578 // either this file name or the old attachment_id, never both1579 // once it has been moved. See includes/receipt-storage.php and1580 // the batched migration on the Checkup screen. Job PHOTOS are1581 // 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. 1582 1584 'private_file' => $wpdb->prepare("ALTER TABLE %i ADD COLUMN private_file VARCHAR(190) NULL", $photos), 1583 1585 ]; -
paintops/trunk/includes/timeoff.php
r3694342 r3755014 23 23 // including it. Kept here too as defense in depth in case that changes. 24 24 if (!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>'; 26 26 return; 27 27 } -
paintops/trunk/paintops.php
r3747168 r3755014 5 5 * Plugin URI: https://paintclarity.com 6 6 * Description: Estimating, scheduling, job tracking, crews, invoicing, and profit reporting for painting contractors. 7 * Version: 1.2.08 07 * Version: 1.2.081 8 8 * Author: PaintClarity 9 9 * Author URI: https://paintclarity.com/the-paintclarity-story/ … … 167 167 } 168 168 } 169 define( 'PAINTOPS_VERSION', '1.2.08 0' );169 define( 'PAINTOPS_VERSION', '1.2.081' ); 170 170 define( 'PAINTOPS_PLUGIN_FILE', __FILE__ ); 171 171 define( 'PAINTOPS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); … … 4949 4949 * per PaintClarity table plus a settings.json and a README. This is an 4950 4950 * 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 4955 4956 * and point to a real full-site backup guide. No import/restore is 4956 4957 * offered yet. Secrets (API keys, tokens) are never exported. … … 5709 5710 } 5710 5711 $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>'; 5712 5713 } 5713 5714 … … 7792 7793 require_once PAINTOPS_PLUGIN_DIR . 'includes/receipt-evidence.php'; 7793 7794 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'; 7794 7799 // Review ask card at the foot of Checkup (free, administrators only). 7795 7800 require_once PAINTOPS_PLUGIN_DIR . 'includes/checkup-review.php'; -
paintops/trunk/readme.txt
r3747168 r3755014 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1.2.08 07 Stable tag: 1.2.081 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 112 112 113 113 = 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.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 from your database and the paintops-private folder from your uploads. 115 115 116 116 = Does it work without OpenAI, Stripe, or Google Maps? = … … 153 153 154 154 == 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 155 159 = 1.2.080 = 156 160 * 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. … … 181 185 * 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. 182 186 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 192 187 The full history is in changelog.txt in the plugin folder.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)