From 7472bd4e76e82f0a29b070eafd1d07943ffd753d Mon Sep 17 00:00:00 2001 From: Fraenkiman Date: Sat, 22 Aug 2026 20:09:51 +0200 Subject: [PATCH] Mediamanager Plugin update to version 2.0.2 - incremental, file-based usage index - The media status no longer needs to be globally rebuilt after changes, which reduces response time --- docs/FlatPress_APCu_Cache_Overview.md | 133 ++- fp-plugins/mediamanager/inc/usage-index.php | 848 ++++++++++++++++++ .../panels/panel.mediamanager.file.php | 124 +-- .../mediamanager/plugin.mediamanager.php | 210 +---- 4 files changed, 980 insertions(+), 335 deletions(-) create mode 100644 fp-plugins/mediamanager/inc/usage-index.php diff --git a/docs/FlatPress_APCu_Cache_Overview.md b/docs/FlatPress_APCu_Cache_Overview.md index 12ad6619..2cdcaa2c 100644 --- a/docs/FlatPress_APCu_Cache_Overview.md +++ b/docs/FlatPress_APCu_Cache_Overview.md @@ -886,6 +886,83 @@ Medium for Mastodon-enabled sites under unfavorable hosting conditions. The APCu --- +### 4.11 Media Manager Usage Index – `mediamanager:usage-index:v1` + +**Logical APCu key:** `mediamanager:usage-index:v1` +**Effective APCu key:** `fp::mediamanager:usage-index:v1` +**Files:** + +- `fp-plugins/mediamanager/inc/usage-index.php` +- `fp-plugins/mediamanager/panels/panel.mediamanager.file.php` + +**Portable file-backed layer:** + +- `fp-content/cache/mediamanager.useindex.json` +- `fp-content/cache/mediamanager.useindex.lock` +- `fp-content/cache/mediamanager.useindex.dirty` + +The JSON index is a **regenerable runtime artifact**. Entry files remain the source of truth. It replaces the historical Media Manager `usecount` array in `settings.conf.php`; after the first successful index persistence, the obsolete `usecount` plugin option is removed best-effort. + +**What is cached:** + +- Direct image-reference counts per normalized relative image path. +- Explicit `[gallery]` reference counts per gallery. +- Per-gallery counts for entries that use the gallery either explicitly or through an image in that gallery. +- Direct-image/explicit-gallery overlap counts, so one entry is never double-counted when it references both. +- Compact per-entry media contributions. These make `entry_saved` and `entry_deleted` updates idempotent, including concurrent cases where a recovery rebuild completes before a waiting hook updates the cache. + +**Build and update behavior:** + +- Missing, corrupt, or dirty state triggers one locked full rebuild. +- The rebuild performs one lightweight `FPDB_Query` over the entry index with `fullparse => false`. +- Each returned entry ID is then loaded exactly once with `entry_parse()`. This reads the entry content without constructing `FPDB_CommentList` objects or changing the historical `FPDB_QueryParams` semantics in core. +- Successful entry writes update only the changed entry through the `entry_saved` hook. +- Successful entry deletions remove only the deleted contribution through the `entry_deleted` hook. +- Preview and other `content_save_pre` paths no longer invalidate the complete Media Manager usage state. +- If no Media Manager index exists yet, entry saves do not force an O(N) rebuild; the first Media Manager request builds it once. + +**Write and concurrency safety:** + +- Writers serialize with `flock()` on `mediamanager.useindex.lock`. +- The JSON payload is written with core `io_write_file()` using the same-directory temp-file/rename path and `fsync` when available. +- A tokenized dirty marker prevents readers from trusting an index while a committed entry change is being folded in. +- One writer never clears a newer writer's dirty token. +- Per-entry contributions make repeated or reordered commit application idempotent. +- If locking or persistence is unavailable, entry saving still succeeds. The Media Manager falls back to a correct in-memory rebuild and never treats the cache as authoritative content. + +**APCu layer:** + +- The plugin uses only the central helpers from `core.apcu.php`: + - `apcu_get()` + - `apcu_set()` + - `apcu_delete_key()` +- The logical key is automatically isolated by `apcu_key()` under the current FlatPress instance namespace. +- APCu stores the complete validated index together with the file signature. +- File signature fields are device, inode, mtime, ctime, size, and the index generation read from the small JSON header. +- A signature mismatch causes a JSON reload, so the file-backed layer remains authoritative across workers or hosting setups that do not share one APCu pool. +- TTL: `600` seconds. The TTL limits retention; file signature validation provides freshness. + +**Media-count semantics:** + +For an image inside a gallery, the displayed use count is the set union of: + +- entries that reference that exact image, and +- entries that explicitly reference its gallery. + +The stored overlap counter subtracts entries present in both sets. Gallery use counts likewise count each entry once whether the gallery is referenced explicitly or only through an image inside it. This preserves the existing Media Manager UX while allowing newly added files in an already-used gallery to show the correct count without rescanning every entry. + +**Fallback behavior:** + +- APCu unavailable: JSON index remains the normal portable cache. +- JSON index missing/corrupt/dirty: one locked full rebuild from entry files. +- JSON persistence unavailable: correct in-memory rebuild for the current request. +- Cache failure never blocks or rolls back an entry write. + +**Impact:** +High on large FlatPress installations. Normal entry edits change the usage index in O(media references of one entry) instead of invalidating an O(all entries) rebuild. APCu reduces JSON decoding and disk reads further but is not required for correctness or performance scaling. + +--- + ## 5. Miscellaneous and Meta Caches ### 5.1 Instance Namespace Bootstrap – `fp:ns:*` @@ -1007,33 +1084,34 @@ Low–Medium (admin-only), but noticeable on slow disks or network filesystems w The following table summarizes each logical cache group: -| Area | Key prefixes (logical) | Depends on PrettyURLs? | Invalidation driver | Approx. impact | -|------------------------------|--------------------------------------------------------------------------------------|--------------------------|------------------------------------------------------|--------------------------| -| APCu core helpers | `fp:ns:*`, `apcu_ns()`, `apcu_key()` | No | N/A (meta only) | High (foundational) | -| Base URL Config | `fp:config:settings:*` | No | File mtime/size via `stat()`, TTL 1h | Medium | -| File I/O | `fp:io:*` | No | File mtime/size, TTL (default 1h) | High | -| Entries | `fp:entry:parsed:*` | No | Entry file mtime/size | High | -| Comments | `fp:comments:list:*`, `fp:comments:count:*` | No | Comment dir mtime, TTL 300s (APCu) + file fallback | Medium–High | -| Static pages | `fp:statics:list:*` | No | Static dir mtime/size, TTL 600s | Medium | -| Categories | `fp:cats:list:*`, `fp:cats:encoded:*` | No | Categories file mtime/size, TTL 600s | Medium | -| Language | `fp:lang:*` | No | Language file mtime/size, locale | Medium–High | -| INI parsing (SEO plugin) | `fp:ini:*` | No | INI file mtime/size | Low–Medium | -| SEO `og:image` (SEO plugin) | `fp:seometa:og:imageinfo:*`, `seometa:og:imagebin:*` | No | Source path/type/mtime/size, target size, TTL | Medium–High | -| Smarty block fragments | `fp:smarty:block:*` | No | TTL, template timestamp, APCu eviction or file fallback | Medium–High | -| HTTPS/IP env | `fp:https:v2:*`, `fp:net:in_cidrs:*` | No | TTL (≈3600s) and local process | Low–Medium | -| Plugin discovery | `fp:plugin:*`, `fp:plugins:*` | No | Plugin dir/config mtimes | Medium | -| Smarty plugin index | `fp:spi:*` | No | Dir+token hash, TTL 300s | Medium | -| Search | `fp:search:rev`, `fp:search:v*` | No | Content rev + TTL (5s / 900s) | Medium | -| BBCode | `fp:bbcode:*` | No | Parser/img/meta mtimes, TTL 300–7200s | Medium–High | -| Archives | `fp:archives:v`, `fp:archives:list*`, `fp:archives:html*` | **Yes** | `plugin_archives_cache_bump()` + PrettyURLs bump | Medium | -| Calendar | `fp:calendar:v`, `calendar:*:vN` | **Yes** | `plugin_calendar_cache_bump()` + PrettyURLs bump | Medium–High | -| Storage plugin | `fp:storage:v`, `fp:storage:aggregate*`, `fp:storage:dirsize*`, `fp:storage:quota*` | No | Storage rescan + TTL | Low–Medium | -| Mastodon instance snapshot | `fp:mastodon:instance_document:` | No | TTL 900s, `instance_url` change, snapshot refresh | Low–Medium | -| Mastodon scheduler summary | core `fp:io:*` for `scheduler-state.json` | No | File mtime/size via core I/O, rebuilt from `state.json` when stale | Medium | -| Mastodon sync guards | `fp:mastodon:sync_guard:content:v1`, `fp:mastodon:sync_guard:deletion:v1` | No | TTL 300s + file guard `sync.guard.json` | Medium | -| Admin setup hide | `fp:admin:setup_hide_report` | No | TTL (ok 86400s, fail 300s) + manual APCu clear | Low–Medium (admin only) | -| PrettyURLs auto-detection | `prettyurls:*`, `prettyurls:auto:v3:g*:*` | No (but influences URLs) | `apcu_gen` bump on mode/.htaccess changes | Medium | -| Maintain panel tools | Uses APCu to clear and inspect all keys, no own namespace | No | Manual admin action | N/A (admin only) | +| Area | Key prefixes (logical) | Depends on PrettyURLs? | Invalidation driver | Approx. impact | +|-----------------------------|-------------------------------------------------------------------------------------|--------------------------|--------------------------------------------------------------------|-------------------------| +| APCu core helpers | `fp:ns:*`, `apcu_ns()`, `apcu_key()` | No | N/A (meta only) | High (foundational) | +| Base URL Config | `fp:config:settings:*` | No | File mtime/size via `stat()`, TTL 1h | Medium | +| File I/O | `fp:io:*` | No | File mtime/size, TTL (default 1h) | High | +| Entries | `fp:entry:parsed:*` | No | Entry file mtime/size | High | +| Comments | `fp:comments:list:*`, `fp:comments:count:*` | No | Comment dir mtime, TTL 300s (APCu) + file fallback | Medium–High | +| Static pages | `fp:statics:list:*` | No | Static dir mtime/size, TTL 600s | Medium | +| Categories | `fp:cats:list:*`, `fp:cats:encoded:*` | No | Categories file mtime/size, TTL 600s | Medium | +| Language | `fp:lang:*` | No | Language file mtime/size, locale | Medium–High | +| INI parsing (SEO plugin) | `fp:ini:*` | No | INI file mtime/size | Low–Medium | +| SEO `og:image` (SEO plugin) | `fp:seometa:og:imageinfo:*`, `seometa:og:imagebin:*` | No | Source path/type/mtime/size, target size, TTL | Medium–High | +| Smarty block fragments | `fp:smarty:block:*` | No | TTL, template timestamp, APCu eviction or file fallback | Medium–High | +| HTTPS/IP env | `fp:https:v2:*`, `fp:net:in_cidrs:*` | No | TTL (≈3600s) and local process | Low–Medium | +| Plugin discovery | `fp:plugin:*`, `fp:plugins:*` | No | Plugin dir/config mtimes | Medium | +| Smarty plugin index | `fp:spi:*` | No | Dir+token hash, TTL 300s | Medium | +| Search | `fp:search:rev`, `fp:search:v*` | No | Content rev + TTL (5s / 900s) | Medium | +| BBCode | `fp:bbcode:*` | No | Parser/img/meta mtimes, TTL 300–7200s | Medium–High | +| Archives | `fp:archives:v`, `fp:archives:list*`, `fp:archives:html*` | **Yes** | `plugin_archives_cache_bump()` + PrettyURLs bump | Medium | +| Calendar | `fp:calendar:v`, `calendar:*:vN` | **Yes** | `plugin_calendar_cache_bump()` + PrettyURLs bump | Medium–High | +| Storage plugin | `fp:storage:v`, `fp:storage:aggregate*`, `fp:storage:dirsize*`, `fp:storage:quota*` | No | Storage rescan + TTL | Low–Medium | +| Mastodon instance snapshot | `fp:mastodon:instance_document:` | No | TTL 900s, `instance_url` change, snapshot refresh | Low–Medium | +| Media Manager usage index | `mediamanager:usage-index:v1` | No | `entry_saved`/`entry_deleted`, dirty recovery | High (large sites) | +| Mastodon scheduler summary | core `fp:io:*` for `scheduler-state.json` | No | File mtime/size via core I/O, rebuilt from `state.json` when stale | Medium | +| Mastodon sync guards | `fp:mastodon:sync_guard:content:v1`, `fp:mastodon:sync_guard:deletion:v1` | No | TTL 300s + file guard `sync.guard.json` | Medium | +| Admin setup hide | `fp:admin:setup_hide_report` | No | TTL (ok 86400s, fail 300s) + manual APCu clear | Low–Medium (admin only) | +| PrettyURLs auto-detection | `prettyurls:*`, `prettyurls:auto:v3:g*:*` | No (but influences URLs) | `apcu_gen` bump on mode/.htaccess changes | Medium | +| Maintain panel tools | Uses APCu to clear and inspect all keys, no own namespace | No | Manual admin action | N/A (admin only) | --- @@ -1064,6 +1142,7 @@ For completeness, the following logical prefixes are used by FlatPress `1.6.dev` - `fp:io:` - `fp:lang:` - `fp:net:in_cidrs:` +- `fp:mediamanager:usage-index:v1` - `fp:mastodon:instance_document:` - `fp:mastodon:sync_guard:content:v1` - `fp:mastodon:sync_guard:deletion:v1` diff --git a/fp-plugins/mediamanager/inc/usage-index.php b/fp-plugins/mediamanager/inc/usage-index.php new file mode 100644 index 00000000..f0e8a983 --- /dev/null +++ b/fp-plugins/mediamanager/inc/usage-index.php @@ -0,0 +1,848 @@ +: prefix. + * + * @return string + */ +function mediamanager_usage_apcu_key() { + return 'mediamanager:usage-index:v' . MEDIAMANAGER_USAGE_INDEX_VERSION; +} + +/** + * @return string + */ +function mediamanager_usage_cache_file() { + return CACHE_DIR . 'mediamanager.useindex.json'; +} + +/** + * @return string + */ +function mediamanager_usage_lock_file() { + return CACHE_DIR . 'mediamanager.useindex.lock'; +} + +/** + * @return string + */ +function mediamanager_usage_dirty_file() { + return CACHE_DIR . 'mediamanager.useindex.dirty'; +} + +/** + * @param int $generation + * @return array + */ +function mediamanager_usage_empty_index($generation = 0) { + return array( + 'version' => MEDIAMANAGER_USAGE_INDEX_VERSION, + 'generation' => max(0, (int)$generation), + 'direct_images' => array(), + 'galleries' => array(), + 'gallery_explicit' => array(), + 'image_gallery_overlap' => array(), + 'entries' => array() + ); +} + +/** + * Normalize one media path or gallery key for the same case-insensitive + * matching semantics that the legacy Media Manager used. + * + * @param mixed $value + * @return string + */ +function mediamanager_usage_normalize_key($value) { + $key = str_replace('\\', '/', (string)$value); + return strtolower($key); +} + +/** + * Normalize an entry contribution stored in the JSON cache. + * + * @param mixed $summary + * @return array{images:array,galleries:array} + */ +function mediamanager_usage_normalize_entry_summary($summary) { + $images = array(); + $galleries = array(); + + if (is_array($summary)) { + if (isset($summary ['images']) && is_array($summary ['images'])) { + foreach ($summary ['images'] as $key) { + $key = mediamanager_usage_normalize_key($key); + if ($key !== '') { + $images [$key] = true; + } + } + } + if (isset($summary ['galleries']) && is_array($summary ['galleries'])) { + foreach ($summary ['galleries'] as $key) { + $key = mediamanager_usage_normalize_key($key); + if ($key !== '') { + $galleries [$key] = true; + } + } + } + } + + $images = array_keys($images); + $galleries = array_keys($galleries); + sort($images, SORT_STRING); + sort($galleries, SORT_STRING); + + return array( + 'images' => $images, + 'galleries' => $galleries + ); +} + +/** + * Extract direct image and explicit gallery references from one entry. + * + * Both lists contain unique, lowercase values. This deliberately mirrors the + * historical Media Manager regex and case-insensitive matching behavior. + * + * @param mixed $content + * @return array{images:array,galleries:array} + */ +function mediamanager_usage_extract_entry($content) { + $content = is_string($content) ? $content : ''; + $images = array(); + $galleries = array(); + + if ($content === '' || stripos($content, 'images/') === false) { + return array( + 'images' => array(), + 'galleries' => array() + ); + } + + $reImg = "/\\[\\s*img\\b[^\\]]*?=\\s*[\"']?images\\/([^\\s\\]\"']+)/iu"; + $reGal = "/\\[\\s*gallery\\b[^\\]]*?=\\s*[\"']?images\\/([^\\s\\]\\/\"']+)/iu"; + + if (preg_match_all($reImg, $content, $matches)) { + foreach ($matches [1] as $rel) { + $rel = mediamanager_usage_normalize_key($rel); + if ($rel !== '') { + $images [$rel] = true; + } + } + } + + if (preg_match_all($reGal, $content, $matches)) { + foreach ($matches [1] as $gallery) { + $gallery = mediamanager_usage_normalize_key($gallery); + if ($gallery !== '') { + $galleries [$gallery] = true; + } + } + } + + $imageKeys = array_keys($images); + $galleryKeys = array_keys($galleries); + sort($imageKeys, SORT_STRING); + sort($galleryKeys, SORT_STRING); + + return array( + 'images' => $imageKeys, + 'galleries' => $galleryKeys + ); +} + +/** + * @param array{images:array,galleries:array} $summary + * @return bool + */ +function mediamanager_usage_summary_is_empty($summary) { + return empty($summary ['images']) && empty($summary ['galleries']); +} + +/** + * Expand one per-entry contribution into the four aggregate sets. + * + * @param array{images:array,galleries:array} $summary + * @return array> + */ +function mediamanager_usage_expand_entry_summary($summary) { + $directImages = array(); + $explicitGalleries = array(); + $usedGalleries = array(); + $overlap = array(); + + foreach ($summary ['galleries'] as $gallery) { + $explicitGalleries [$gallery] = true; + $usedGalleries [$gallery] = true; + } + + foreach ($summary ['images'] as $rel) { + $directImages [$rel] = true; + $slash = strpos($rel, '/'); + if ($slash === false) { + continue; + } + + $gallery = substr($rel, 0, $slash); + if ($gallery === '') { + continue; + } + + $usedGalleries [$gallery] = true; + if (isset($explicitGalleries [$gallery])) { + $overlap [$rel] = true; + } + } + + return array( + 'direct_images' => $directImages, + 'galleries' => $usedGalleries, + 'gallery_explicit' => $explicitGalleries, + 'image_gallery_overlap' => $overlap + ); +} + +/** + * Apply a set delta to one aggregate counter map. + * + * @param array $counter + * @param array $oldSet + * @param array $newSet + * @return void + */ +function mediamanager_usage_apply_counter_delta(&$counter, $oldSet, $newSet) { + foreach ($oldSet as $key => $unused) { + if (isset($newSet [$key])) { + continue; + } + $current = isset($counter [$key]) ? (int)$counter [$key] : 0; + $current--; + if ($current > 0) { + $counter [$key] = $current; + } else { + unset($counter [$key]); + } + } + + foreach ($newSet as $key => $unused) { + if (isset($oldSet [$key])) { + continue; + } + $counter [$key] = isset($counter [$key]) ? ((int)$counter [$key] + 1) : 1; + } +} + +/** + * Replace one entry contribution in an already-valid index. + * + * @param array $index + * @param string $entryId + * @param array{images:array,galleries:array} $newSummary + * @return bool True when aggregate data changed. + */ +function mediamanager_usage_replace_entry(&$index, $entryId, $newSummary) { + $entryId = (string)$entryId; + $oldSummary = isset($index ['entries'] [$entryId]) ? mediamanager_usage_normalize_entry_summary($index ['entries'] [$entryId]) : array('images' => array(), 'galleries' => array()); + $newSummary = mediamanager_usage_normalize_entry_summary($newSummary); + + if ($oldSummary == $newSummary) { + return false; + } + + $oldExpanded = mediamanager_usage_expand_entry_summary($oldSummary); + $newExpanded = mediamanager_usage_expand_entry_summary($newSummary); + + foreach (array('direct_images', 'galleries', 'gallery_explicit', 'image_gallery_overlap') as $bucket) { + mediamanager_usage_apply_counter_delta( + $index [$bucket], + $oldExpanded [$bucket], + $newExpanded [$bucket] + ); + } + + if (mediamanager_usage_summary_is_empty($newSummary)) { + unset($index ['entries'] [$entryId]); + } else { + $index ['entries'] [$entryId] = $newSummary; + } + + return true; +} + +/** + * @param mixed $counter + * @return array + */ +function mediamanager_usage_normalize_counter($counter) { + $out = array(); + if (!is_array($counter)) { + return $out; + } + + foreach ($counter as $key => $value) { + $key = mediamanager_usage_normalize_key($key); + $value = (int)$value; + if ($key !== '' && $value > 0) { + $out [$key] = $value; + } + } + ksort($out, SORT_STRING); + return $out; +} + +/** + * Normalize and validate a decoded JSON index. + * + * @param mixed $data + * @return array|null + */ +function mediamanager_usage_normalize_index($data) { + if (!is_array($data) || !isset($data ['version']) || (int)$data ['version'] !== MEDIAMANAGER_USAGE_INDEX_VERSION || !isset($data ['direct_images'], $data ['galleries'], $data ['gallery_explicit'], $data ['image_gallery_overlap'], $data ['entries']) || !is_array($data ['entries'])) { + return null; + } + + $index = mediamanager_usage_empty_index(isset($data ['generation']) ? (int)$data ['generation'] : 0); + $index ['direct_images'] = mediamanager_usage_normalize_counter($data ['direct_images']); + $index ['galleries'] = mediamanager_usage_normalize_counter($data ['galleries']); + $index ['gallery_explicit'] = mediamanager_usage_normalize_counter($data ['gallery_explicit']); + $index ['image_gallery_overlap'] = mediamanager_usage_normalize_counter($data ['image_gallery_overlap']); + + foreach ($data ['entries'] as $entryId => $summary) { + $summary = mediamanager_usage_normalize_entry_summary($summary); + if (!mediamanager_usage_summary_is_empty($summary)) { + $index ['entries'] [(string)$entryId] = $summary; + } + } + ksort($index ['entries'], SORT_STRING); + + return $index; +} + +/** + * Fast structural validation for APCu payloads produced by this code. + * + * @param mixed $data + * @return bool + */ +function mediamanager_usage_index_has_valid_shape($data) { + return is_array($data) && isset($data ['version']) && (int)$data ['version'] === MEDIAMANAGER_USAGE_INDEX_VERSION && isset($data ['direct_images'], $data ['galleries'], $data ['gallery_explicit'], $data ['image_gallery_overlap'], $data ['entries']) && is_array($data ['direct_images']) && is_array($data ['galleries']) && is_array($data ['gallery_explicit']) && is_array($data ['image_gallery_overlap']) && is_array($data ['entries']); +} + +/** + * Return a lightweight signature for the atomically replaced JSON file. + * + * inode/device are included where available. The index generation is read from + * the small JSON header as a portable fallback for filesystems with coarse + * timestamp resolution or no useful inode value. + * + * @param string $file + * @return string|null + */ +function mediamanager_usage_file_signature($file) { + $stat = @stat($file); + if (!is_array($stat)) { + return null; + } + + $generation = 'unknown'; + $handle = @fopen($file, 'rb'); + if (is_resource($handle)) { + $head = @fread($handle, 512); + @fclose($handle); + if (preg_match('/"generation"\s*:\s*([0-9]+)/', $head, $match)) { + $generation = $match [1]; + } + } + + $parts = array( + isset($stat ['dev']) ? (string)$stat ['dev'] : '0', + isset($stat ['ino']) ? (string)$stat ['ino'] : '0', + isset($stat ['mtime']) ? (string)$stat ['mtime'] : '0', + isset($stat ['ctime']) ? (string)$stat ['ctime'] : '0', + isset($stat ['size']) ? (string)$stat ['size'] : '0', + $generation + ); + return implode(':', $parts); +} + +/** + * Read the portable JSON layer, using APCu as an optional front cache. + * + * @param bool $ignoreDirty Used only while holding the write lock. + * @return array|null + */ +function mediamanager_usage_read_index($ignoreDirty = false) { + $file = mediamanager_usage_cache_file(); + if (!$ignoreDirty && is_file(mediamanager_usage_dirty_file())) { + return null; + } + + $signature = mediamanager_usage_file_signature($file); + if ($signature === null) { + apcu_delete_key(mediamanager_usage_apcu_key()); + return null; + } + + $hit = false; + $cached = apcu_get(mediamanager_usage_apcu_key(), $hit); + if ($hit && is_array($cached) && isset($cached ['signature'], $cached ['index']) && $cached ['signature'] === $signature && mediamanager_usage_index_has_valid_shape($cached ['index'])) { + return $cached ['index']; + } + + $json = io_load_file_uncached($file, true); + if (!is_string($json) || $json === '') { + apcu_delete_key(mediamanager_usage_apcu_key()); + return null; + } + + $decoded = json_decode($json, true); + $index = mediamanager_usage_normalize_index($decoded); + if ($index === null) { + apcu_delete_key(mediamanager_usage_apcu_key()); + return null; + } + + apcu_set( + mediamanager_usage_apcu_key(), + array('signature' => $signature, 'index' => $index), + MEDIAMANAGER_USAGE_APCU_TTL + ); + return $index; +} + +/** + * Persist the index atomically and refresh the instance-namespaced APCu layer. + * + * @param array $index + * @return bool + */ +function mediamanager_usage_write_index($index) { + $index = mediamanager_usage_normalize_index($index); + if ($index === null) { + return false; + } + + $json = json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (!is_string($json)) { + return false; + } + $json .= "\n"; + + $file = mediamanager_usage_cache_file(); + if (!io_write_file($file, $json, array('fsync' => true))) { + apcu_delete_key(mediamanager_usage_apcu_key()); + return false; + } + + clearstatcache(true, $file); + $signature = mediamanager_usage_file_signature($file); + if ($signature !== null) { + apcu_set( + mediamanager_usage_apcu_key(), + array('signature' => $signature, 'index' => $index), + MEDIAMANAGER_USAGE_APCU_TTL + ); + } else { + apcu_delete_key(mediamanager_usage_apcu_key()); + } + + return true; +} + +/** + * @return resource|false + */ +function mediamanager_usage_lock() { + if (!fs_mkdir(CACHE_DIR)) { + return false; + } + + $lock = @fopen(mediamanager_usage_lock_file(), 'c'); + if (!is_resource($lock)) { + return false; + } + if (!@flock($lock, LOCK_EX)) { + @fclose($lock); + return false; + } + return $lock; +} + +/** + * @param mixed $lock + * @return void + */ +function mediamanager_usage_unlock($lock) { + if (!is_resource($lock)) { + return; + } + @flock($lock, LOCK_UN); + @fclose($lock); +} + +/** + * Mark the index as not safe for readers while a committed entry change is + * being folded in. The token prevents one concurrent writer from clearing a + * newer writer's dirty marker. + * + * @return string|false + */ +function mediamanager_usage_mark_dirty() { + apcu_delete_key(mediamanager_usage_apcu_key()); + + try { + $token = bin2hex(random_bytes(12)); + } catch (\Throwable $e) { + $token = sha1(uniqid('', true) . mt_rand()); + } + + $payload = $token . "\n"; + if (io_write_file(mediamanager_usage_dirty_file(), $payload, array('fsync' => true))) { + return $token; + } + + /** + * If the marker itself cannot be written, prefer a cold cache over stale + * data. Failure to remove a no-longer-writable cache is still non-fatal to + * the entry write; entry data remains the source of truth. + */ + @unlink(mediamanager_usage_cache_file()); + apcu_delete_key(mediamanager_usage_apcu_key()); + return false; +} + +/** + * Remove only the dirty marker that belongs to this writer/rebuilder. + * + * @param string|false $token + * @return void + */ +function mediamanager_usage_clear_dirty($token) { + if (!is_string($token) || $token === '') { + return; + } + $file = mediamanager_usage_dirty_file(); + $current = @file_get_contents($file); + if (is_string($current) && trim($current) === $token) { + @unlink($file); + } +} + +/** + * Read the current dirty token. Used by recovery rebuilds so they only clear + * the marker they actually recovered. + * + * @return string|false + */ +function mediamanager_usage_current_dirty_token() { + $current = @file_get_contents(mediamanager_usage_dirty_file()); + if (!is_string($current)) { + return false; + } + $current = trim($current); + return $current !== '' ? $current : false; +} + +/** + * Build the complete index with one full entry pass and no comments. + * + * @param int $generation + * @return array + */ +function mediamanager_usage_rebuild_index($generation = 0) { + $index = mediamanager_usage_empty_index($generation); + $query = new FPDB_Query( + array( + 'start' => 0, + 'count' => -1, + 'fullparse' => false + ), + null + ); + + while ($query->hasMore()) { + $entry = $query->getEntry(); + if (!is_array($entry) || !isset($entry [0])) { + continue; + } + + $entryId = (string)$entry [0]; + $parsed = entry_parse($entryId); + if (!is_array($parsed)) { + continue; + } + + $content = isset($parsed ['content']) ? $parsed ['content'] : ''; + $summary = mediamanager_usage_extract_entry($content); + if (mediamanager_usage_summary_is_empty($summary)) { + continue; + } + + mediamanager_usage_replace_entry($index, $entryId, $summary); + } + + $index ['generation'] = max(0, (int)$generation) + 1; + return $index; +} + +/** + * Drop the historical plugin option after the new runtime index was safely + * persisted. This moves regenerable data out of settings.conf.php. + * + * Failure is harmless: the old option is ignored by the new code. + * + * @return void + */ +function mediamanager_usage_cleanup_legacy_config() { + global $fp_config; + static $attempted = false; + + if ($attempted) { + return; + } + $attempted = true; + + if (!isset($fp_config ['plugins'] ['mediamanager']) || !is_array($fp_config ['plugins'] ['mediamanager']) || !array_key_exists('usecount', $fp_config ['plugins'] ['mediamanager'])) { + return; + } + + unset($fp_config ['plugins'] ['mediamanager'] ['usecount']); + if (function_exists('plugin_saveoptions')) { + @plugin_saveoptions('mediamanager'); + } +} + +/** + * Return a valid index. Missing/corrupt/dirty state causes one locked rebuild. + * + * If locking or persistence is unavailable, a correct in-memory rebuild is + * still returned for this request; no entry write is ever made dependent on + * the cache. + * + * @return array + */ +function mediamanager_usage_get_index() { + $index = mediamanager_usage_read_index(false); + if ($index !== null) { + mediamanager_usage_cleanup_legacy_config(); + return $index; + } + + $lock = mediamanager_usage_lock(); + if ($lock === false) { + return mediamanager_usage_rebuild_index(0); + } + + try { + $dirtyToken = mediamanager_usage_current_dirty_token(); + $index = mediamanager_usage_read_index(false); + if ($index !== null) { + mediamanager_usage_cleanup_legacy_config(); + return $index; + } + + $previous = mediamanager_usage_read_index(true); + $generation = is_array($previous) && isset($previous ['generation']) ? (int)$previous ['generation'] : 0; + + $index = mediamanager_usage_rebuild_index($generation); + if (mediamanager_usage_write_index($index)) { + mediamanager_usage_clear_dirty($dirtyToken); + mediamanager_usage_cleanup_legacy_config(); + } + return $index; + } finally { + mediamanager_usage_unlock($lock); + } +} + +/** + * Apply a committed entry state to the existing index. + * + * Per-entry contributions make this operation idempotent. If another request + * rebuilt the index after the entry commit but before this hook acquired the + * lock, replace_entry() observes the already-current contribution and does + * not double-count it. + * + * @param string $entryId + * @param mixed $content + * @return void + */ +function mediamanager_usage_commit_entry($entryId, $content) { + $summary = mediamanager_usage_extract_entry($content); + $file = mediamanager_usage_cache_file(); + + // Before the first Media Manager rebuild there is nothing to update. Avoid + // turning an entry save into an O(N) operation. + if (!is_file($file)) { + apcu_delete_key(mediamanager_usage_apcu_key()); + return; + } + + $dirtyToken = mediamanager_usage_mark_dirty(); + $lock = mediamanager_usage_lock(); + if ($lock === false) { + return; + } + + try { + $index = mediamanager_usage_read_index(true); + if ($index === null) { + return; + } + + if (!mediamanager_usage_replace_entry($index, (string)$entryId, $summary)) { + mediamanager_usage_clear_dirty($dirtyToken); + return; + } + + $index ['generation'] = isset($index ['generation']) ? ((int)$index ['generation'] + 1) : 1; + if (mediamanager_usage_write_index($index)) { + mediamanager_usage_clear_dirty($dirtyToken); + mediamanager_usage_cleanup_legacy_config(); + } + } finally { + mediamanager_usage_unlock($lock); + } +} + +/** + * Remove one committed entry contribution. + * + * @param string $entryId + * @return void + */ +function mediamanager_usage_remove_entry($entryId) { + $file = mediamanager_usage_cache_file(); + if (!is_file($file)) { + apcu_delete_key(mediamanager_usage_apcu_key()); + return; + } + + $dirtyToken = mediamanager_usage_mark_dirty(); + $lock = mediamanager_usage_lock(); + if ($lock === false) { + return; + } + + try { + $index = mediamanager_usage_read_index(true); + if ($index === null) { + return; + } + + $empty = array('images' => array(), 'galleries' => array()); + if (!mediamanager_usage_replace_entry($index, (string)$entryId, $empty)) { + mediamanager_usage_clear_dirty($dirtyToken); + return; + } + + $index ['generation'] = isset($index ['generation']) ? ((int)$index ['generation'] + 1) : 1; + if (mediamanager_usage_write_index($index)) { + mediamanager_usage_clear_dirty($dirtyToken); + mediamanager_usage_cleanup_legacy_config(); + } + } finally { + mediamanager_usage_unlock($lock); + } +} + +/** + * Materialize the Media Manager display count for one filesystem item. + * + * For an image inside a gallery this is a set union: + * direct-image entries U explicit-gallery entries. + * + * @param array $index + * @param string $relpath + * @param string $type + * @return int + */ +function mediamanager_usage_count_for($index, $relpath, $type) { + if (!mediamanager_usage_index_has_valid_shape($index)) { + return 0; + } + + $key = mediamanager_usage_normalize_key($relpath); + if ($key === '') { + return 0; + } + + if ($type === 'gallery') { + return isset($index ['galleries'] [$key]) ? (int)$index ['galleries'] [$key] : 0; + } + if ($type !== 'images') { + return 0; + } + + $direct = isset($index ['direct_images'] [$key]) ? (int)$index ['direct_images'] [$key] : 0; + $slash = strpos($key, '/'); + if ($slash === false) { + return $direct; + } + + $gallery = substr($key, 0, $slash); + $viaGallery = isset($index ['gallery_explicit'] [$gallery]) ? (int)$index ['gallery_explicit'] [$gallery] : 0; + $overlap = isset($index ['image_gallery_overlap'] [$key]) ? (int)$index ['image_gallery_overlap'] [$key] : 0; + + return max(0, $direct + $viaGallery - $overlap); +} + +/** + * @param array $index + * @return array + */ +function mediamanager_usage_used_galleries($index) { + $used = array(); + if (!mediamanager_usage_index_has_valid_shape($index)) { + return $used; + } + + foreach ($index ['galleries'] as $gallery => $count) { + if ((int)$count > 0) { + $used [(string)$gallery] = true; + } + } + return $used; +} + +/** + * Successful entry-save hook. + * + * @param mixed $entryId + * @param mixed $entry + * @param mixed $oldEntry + * @param mixed $isUpdate + * @return void + */ +function mediamanager_usage_on_entry_saved($entryId, $entry, $oldEntry = array(), $isUpdate = false) { + $content = is_array($entry) && isset($entry ['content']) ? $entry ['content'] : ''; + mediamanager_usage_commit_entry((string)$entryId, $content); +} + +/** + * Successful entry-delete hook. + * + * @param mixed $entryId + * @param mixed $oldEntry + * @return void + */ +function mediamanager_usage_on_entry_deleted($entryId, $oldEntry = array()) { + mediamanager_usage_remove_entry((string)$entryId); +} +?> diff --git a/fp-plugins/mediamanager/panels/panel.mediamanager.file.php b/fp-plugins/mediamanager/panels/panel.mediamanager.file.php index ca80d531..4590fada 100644 --- a/fp-plugins/mediamanager/panels/panel.mediamanager.file.php +++ b/fp-plugins/mediamanager/panels/panel.mediamanager.file.php @@ -8,7 +8,7 @@ class admin_uploader_mediamanager extends AdminPanelAction { var $finfo; - var $conf; + var $usage_index = array(); var $langres = 'plugin:mediamanager'; @@ -108,55 +108,11 @@ function getDirBytes($dir) { return $sum; } - /** - * Detect used gallery folders by scanning entries once. - * Returns an array of lowercase gallery names. - * Used only when no usecount data is available yet. - */ - function detect_used_galleries() { - $found = array(); - $q = new FPDB_Query(array('start' => 0, 'count' => -1, 'fullparse' => false), null); - - // IMG: [img="images/" ...] or [img=images/ ...] - // - optional " or ' after '=' - // - Path ends before space, ], " or ' - // - additional attributes allowed - $reImg = "/\\[\\s*img\\b[^\\]]*?=\\s*[\"']?images\\/([^\\s\\]\"']+)/iu"; - - // GALLERY: [gallery=\"images//\" ...] or without Quotes/Slash - // - optional " or ' after '=' - // - optional trailing slash - // - additional attributes allowed - $reGal = "/\\[\\s*gallery\\b[^\\]]*?=\\s*[\"']?images\\/([^\\s\\]\\/\"']+)/iu"; - - while ($q->hasMore()) { - list($entryId, $e) = $q->getEntry(); - if (empty($e ['content'])) { - continue; - } - $c = $e ['content']; - if (preg_match_all($reImg, $c, $m)) { - foreach ($m [1] as $rel) { - $p = strpos($rel, '/'); - if ($p !== false) { - $g = strtolower(substr($rel, 0, $p)); - $found [$g] = true; - } - } - } - if (preg_match_all($reGal, $c, $mg)) { - foreach ($mg [1] as $g) { - $found [strtolower($g)] = true; - } - } - } - return array_keys($found); - } - /** * Get formatted file information including size and modified time. * * @param string $filepath Absolute filesystem path. + * @param string $type Media Manager item type. * @return array{ * name:string, * relpath:string, @@ -167,7 +123,7 @@ function detect_used_galleries() { * use_via_gallery:bool * }|null */ - function getFileInfo($filepath) { + function getFileInfo($filepath, $type = 'images') { global $fp_config; // Prevents the capture of .dlctr (Igor Kromins DownloadCounter) files @@ -213,22 +169,16 @@ function getFileInfo($filepath) { "mtime" => date_strformat($fp_config ['locale'] ['dateformatshort'], $file_mtime) ); - // Read via relative path, fallback to old key (base name) for backward compatibility - if (isset($this->conf ['usecount'] [$info ['relpath']])) { - $info ['usecount'] = $this->conf ['usecount'] [$info ['relpath']]; - } elseif (isset($this->conf ['usecount'] [basename($filepath)])) { - $info ['usecount'] = $this->conf ['usecount'] [basename($filepath)]; - } else { - $info ['usecount'] = null; - } + $info ['usecount'] = mediamanager_usage_count_for($this->usage_index, $info ['relpath'], $type); - // Gallery name and usage flag - $info ['gallery'] = (strpos($rel, '/')!== false) ? substr($rel, 0 ,strpos($rel, '/')) : null; - if (isset($this->conf ['useflags'] [$info ['relpath']])) { - $flags = $this->conf ['useflags'] [$info ['relpath']]; - $info ['use_via_gallery'] = is_array($flags) && !empty($flags ['gallery']); - } else { - $info ['use_via_gallery'] = false; + // Gallery name and whether an image is also covered by an explicit + // [gallery] reference. This keeps the existing template data contract. + $info ['gallery'] = (strpos($rel, '/') !== false) ? substr($rel, 0, strpos($rel, '/')) : null; + $info ['use_via_gallery'] = false; + if ($type === 'images' && $info ['gallery'] !== null) { + $galleryKey = mediamanager_usage_normalize_key($info ['gallery']); + $info ['use_via_gallery'] = isset($this->usage_index ['gallery_explicit'] [$galleryKey]) + && (int)$this->usage_index ['gallery_explicit'] [$galleryKey] > 0; } return $info; @@ -343,55 +293,31 @@ function main() { } $weburl = plugin_geturl('mediamanager'); - $this->conf = plugin_getoptions('mediamanager'); - - // Build usage map from usecount if available - $this->used_galleries = array(); - if (isset($this->conf ['usecount']) && is_array($this->conf ['usecount'])) { - foreach ($this->conf['usecount'] as $k => $v) { - if ((int)$v > 0) { - // images// -> gallery - // (gallery item) -> gallery - $g = strtolower((strpos($k, '/') !== false) ? substr($k, 0, strpos($k, '/')) : $k); - if ($g !== '') { - $this->used_galleries [$g] = true; - } - } - } - } - // First-load fallback: if empty, detect directly from entries (one pass) - if (empty($this->used_galleries)) { - $det = $this->detect_used_galleries(); - foreach ($det as $g) { - $this->used_galleries [strtolower($g)] = true; - } - } + // Item actions do not depend on entry usage data. Handle them before a + // possible first-run/recovery rebuild of the usage index. if ($this->doItemActions($folder, $mmbaseurl)) { return; } + $this->usage_index = mediamanager_usage_get_index(); + $this->used_galleries = mediamanager_usage_used_galleries($this->usage_index); + $files = array(); $galleries = array(); - $files_needupdate = array(); - $galleries_needupdate = array(); - // Galleries (always from IMAGES_DIR) if (file_exists(ABS_PATH . IMAGES_DIR)) { if ($dir = opendir(ABS_PATH . IMAGES_DIR)) { while (false !== ($file = readdir($dir))) { $fullpath = ABS_PATH . IMAGES_DIR . $file; if (!fs_is_directorycomponent($file) && !fs_is_hidden_file($file) && is_dir($fullpath)) { - $info = $this->getFileInfo($fullpath); + $info = $this->getFileInfo($fullpath, 'gallery'); if ($info) { $info ['type'] = "gallery"; // Mark folder usage for template icon $info ['used_in_posts'] = !empty($this->used_galleries [strtolower($info ['name'])]); $galleries [$fullpath] = $info; - if (is_null($info ['usecount'])) { - $galleries_needupdate [] = $fullpath; - } } } } @@ -405,14 +331,11 @@ function main() { while (false !== ($file = readdir($dir))) { if (!fs_is_directorycomponent($file) && !fs_is_hidden_file($file)) { $fullpath = ABS_PATH . ATTACHS_DIR . $file; - $info = $this->getFileInfo($fullpath); + $info = $this->getFileInfo($fullpath, 'attachs'); if ($info) { $info ['type'] = "attachs"; $info ['url'] = BLOG_ROOT . ATTACHS_DIR . $file; $files [$fullpath] = $info; - if (is_null($info ['usecount'])) { - $files_needupdate [] = $fullpath; - } } } } @@ -426,15 +349,11 @@ function main() { while (false !== ($file = readdir($dir))) { $fullpath = ABS_PATH . IMAGES_DIR . $folder . $file; if (!fs_is_directorycomponent($file) && !fs_is_hidden_file($file) && !is_dir($fullpath)) { - $info = $this->getFileInfo($fullpath); + $info = $this->getFileInfo($fullpath, 'images'); if ($info) { $info ['type'] = "images"; $info ['url'] = BLOG_ROOT . IMAGES_DIR . $folder . $file; $files [$fullpath] = $info; - // Always maintain, not just in the root folder - if (is_null($info ['usecount'])) { - $files_needupdate [] = $fullpath; - } } } } @@ -442,9 +361,6 @@ function main() { } } - mediamanager_updateUseCountArr($files, $files_needupdate); - mediamanager_updateUseCountArr($galleries, $galleries_needupdate); - // Derive used_in_posts after counts/flags were updated if (!empty($galleries)) { foreach ($galleries as &$inUse) { diff --git a/fp-plugins/mediamanager/plugin.mediamanager.php b/fp-plugins/mediamanager/plugin.mediamanager.php index b701739f..8c173777 100644 --- a/fp-plugins/mediamanager/plugin.mediamanager.php +++ b/fp-plugins/mediamanager/plugin.mediamanager.php @@ -1,7 +1,7 @@ $info) { - if (!isset($info ['type']) || $info ['type'] !== 'images') { - continue; - } - $rel = isset($info ['relpath']) ? $info ['relpath'] : ''; - if ($rel !== '' && strpos($rel, '/') !== false) { - $g = strtolower(substr($rel, 0, strpos($rel, '/'))); - if (isset($galleriesTouched [$g])) { - $targets [] = $fid; - } - } - } - // uniq - $targets = array_values(array_unique($targets, SORT_REGULAR)); - } - - // Nothing to update -> skip costly entry scan - if (empty($targets)) { - return; - } - - // Lookup tables: one-time - $imgMap = array(); // relpath(lower) => [fileIds] - $galToImg = array(); // gallery(lower) => [image fileIds] - $galItems = array(); // gallery(lower) => gallery fileId - foreach ($targets as $fid) { - if (!isset($files [$fid] ['usecount'])) { - $files [$fid] ['usecount'] = 0; - } - $type = isset($files [$fid] ['type']) ? $files [$fid] ['type'] : ''; - if ($type === 'images') { - $rel = (isset($files [$fid] ['relpath']) && $files [$fid] ['relpath'] !== '') ? $files [$fid] ['relpath'] : $files [$fid] ['name']; - $k = strtolower($rel); - $imgMap [$k] [] = $fid; - if (strpos($rel, '/') !== false) { - $g = strtolower(substr($rel, 0, strpos($rel, '/'))); - $galToImg [$g] [] = $fid; - } - } elseif ($type === 'gallery') { - $galItems [strtolower($files [$fid] ['name'])] = $fid; - } - } - - // Short-circuit galleries pass using per-entry counts from first pass - if (!empty($galItems) && !empty($GLOBALS ['mm_gal_entry_count'])) { - foreach ($GLOBALS ['mm_gal_entry_count'] as $g => $n) { - if (isset($galItems[$g])) { - $files [$galItems [$g]] ['usecount'] += (int)$n; - } - } - // Mark built, to avoid re-adding - $GLOBALS['mm_gal_count_built'] = true; - // Skip entry scanning for galleries - goto MM_PERSIST; - } - // Once over all entries - $q = new FPDB_Query(array('start' => 0, 'count' => -1, 'fullparse' => true), null); - - // IMG: [img="images/" ...] or [img=images/ ...] - // - optional " or ' after '=' - // - Path ends before space, ], " or ' - // - additional attributes allowed - $reImg = "/\\[\\s*img\\b[^\\]]*?=\\s*[\"']?images\\/([^\\s\\]\"']+)/iu"; - - // GALLERY: [gallery=\"images//\" ...] or without Quotes/Slash - // - optional " or ' after '=' - // - optional trailing slash - // - additional attributes allowed - $reGal = "/\\[\\s*gallery\\b[^\\]]*?=\\s*[\"']?images\\/([^\\s\\]\\/\"']+)/iu"; - - while ($q->hasMore()) { - list($entryId, $e) = $q->getEntry(); - if (empty($e ['content'])) { - continue; - } - $c = $e ['content']; - $counted = array(); - $countedGalFromImg = array(); - - // Direct image uses (once per unique relpath) - if ($imgMap && (stripos($c,'images/')!==false) && preg_match_all($reImg, $c, $m)) { - $rels = array_unique($m [1]); - foreach ($rels as $rel) { - $k = strtolower($rel); - if (!isset($imgMap [$k])) { - continue; - } - foreach ($imgMap [$k] as $fid) { - if (isset($counted [$fid])) { - continue; - } - $files [$fid] ['usecount']++; - $counted [$fid] = true; - } - } - } - - // Fallback: if images are linked but not part of $targets, still count their gallery once per entry - if (!empty($galItems) && preg_match_all($reImg, $c, $m)) { - $rels = array_unique($m [1]); - foreach ($rels as $rel) { - $p = strpos($rel, '/'); - if ($p === false) { - continue; - } - $g = strtolower(substr($rel, 0, $p)); - if (isset($galItems [$g]) && !isset($counted [$galItems [$g]]) && !isset($countedGalFromImg [$g])) { - $files [$galItems [$g]] ['usecount']++; - $counted [$galItems [$g]] = true; - $countedGalFromImg [$g] = true; - } - } - } - - // Gallery uses (once per unique gallery); only count files that have not yet been counted in this entry - if (($galToImg || $galItems) && (stripos($c,'images/')!==false) && preg_match_all($reGal, $c, $mg)) { - $gals = array_unique($mg[1]); - // Accumulate per-entry gallery counts (files-pass) - if (empty($galItems) && !$GLOBALS ['mm_gal_count_built']) { - foreach ($gals as $gcount) { - $gcount = strtolower($gcount); - $GLOBALS ['mm_gal_entry_count'] [$gcount] = isset($GLOBALS ['mm_gal_entry_count'] [$gcount]) ? ($GLOBALS ['mm_gal_entry_count'] [$gcount]+1) : 1; - } - } - - foreach ($gals as $g) { - $g = strtolower($g); - if (isset($galItems [$g]) && !isset($counted [$galItems [$g]])) { - $files [$galItems [$g]] ['usecount']++; - $counted [$galItems [$g]] = true; - } - if (isset($galToImg [$g])) { - foreach ($galToImg [$g] as $fid) { - if (isset($counted [$fid])) { - continue; - } - $files [$fid] ['usecount']++; - $counted [$fid] = true; - } - } - } - } - } - - MM_PERSIST: - // Persistence: only usecount (key = relpath, fallback name) - $usecount = array(); - foreach ($files as $fid => $info) { - if (!isset($info ['name'], $info ['usecount'])) { - continue; - } - $key = (!empty($info ['relpath'])) ? $info ['relpath'] : $info ['name']; - $usecount [$key] = (int)$info ['usecount']; - } - if (!empty($usecount)) { - $opts = plugin_getoptions('mediamanager'); - $old = (isset($opts['usecount']) && is_array($opts ['usecount'])) ? $opts ['usecount'] : array(); - $new = $old; - foreach ($usecount as $k => $v) { - $new [$k] = $v; - } - if ($new !== $old) { - plugin_addoption('mediamanager', 'usecount', $new); - plugin_saveoptions('mediamanager'); - } - } -} - if (class_exists('AdminPanelAction')) { + require_once plugin_getdir('mediamanager') . '/inc/usage-index.php'; include (plugin_getdir('mediamanager') . '/panels/panel.mediamanager.file.php'); } -/** - * Invalidate count on entry save and delete - */ -function mediamanager_invalidatecount($arg) { - plugin_addoption('mediamanager', 'usecount', array()); - plugin_saveoptions('mediamanager'); - return $arg; -} -add_filter('delete_post', 'mediamanager_invalidatecount', 1); -add_filter('content_save_pre', 'mediamanager_invalidatecount', 1); +// Maintain the regenerable Media Manager usage index only after successful +// entry commits. Preview/content_save_pre paths must not invalidate it. +add_action('entry_saved', 'mediamanager_usage_on_entry_saved', 10, 4); +add_action('entry_deleted', 'mediamanager_usage_on_entry_deleted', 10, 2); ?>