diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs
index e73fcdc..44e8510 100644
--- a/.github/scripts/release-guard.cjs
+++ b/.github/scripts/release-guard.cjs
@@ -1,11 +1,11 @@
'use strict';
-// 仅用于本次已授权的 v1.14.2;失败或部分发布必须人工核查,绝不覆盖资产。
+// 仅用于本次已授权的 v1.15.0;失败或部分发布必须人工核查,绝不覆盖资产。
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
-const VERSION = '1.14.2';
+const VERSION = '1.15.0';
const TAG = `v${VERSION}`;
const ASSETS = ['infinitytime-theme.zip', 'infinitytime-plugin.zip'];
function insist(ok, message) { if (!ok) throw new Error(message); }
@@ -22,9 +22,9 @@ function guardRun(event, repository, mainSha, run, workflow) {
return mainSha;
}
function guardVersions(theme, plugin, release) {
- insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.14.2');
- insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.2');
- insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.2');
+ insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.15.0');
+ insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.15.0');
+ insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.15.0');
}
function guardExisting(tagSha, release, sha, digests) {
if (!tagSha && !release) return 'create';
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3b60e64..72e18e6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,12 +45,14 @@ jobs:
php tests/schema-sqlite.php
php tests/field-sync.php
php tests/image-delete.php
+ php tests/media-version.php
php tests/media-lifetime.php
php tests/media-gd.php
php tests/media-command.php
php tests/media-temp.php
+ php tests/health-diagnostics.php
php tests/maintenance-state.php
- for mode in rebuild-sync-failure rebuild-success rebuild-batch rebuild-restart rebuild-resume rebuild-start-reuse rebuild-no-original rebuild-missing-original rebuild-conversion-failure stale-poll finished-poll delete-non-plugin delete-rollback lease-conflict; do
+ for mode in scan-failure retry-image retry-fields retry-mixed retry-resync retry-repeat retry-finished retry-stale retry-empty retry-unfinished retry-other-job errors-page errors-stale resync-failure rebuild-sync-failure rebuild-success rebuild-batch rebuild-restart rebuild-resume rebuild-start-reuse rebuild-no-original rebuild-missing-original rebuild-conversion-failure stale-poll finished-poll delete-non-plugin delete-rollback lease-conflict; do
php tests/maintenance-panel.php "$mode"
done
php tests/admin-workflow.php
@@ -65,6 +67,7 @@ jobs:
node tests/maintenance-client.cjs
node --test tests/release-guards.cjs
node tests/gallery.cjs
+ node tests/data-saver.cjs
node tests/gallery-loading.cjs
node tests/gallery-navigation.cjs
@@ -108,6 +111,9 @@ jobs:
- name: 后台上传浏览器回归
if: ${{ !cancelled() }}
run: node tests/admin-upload.cjs --browser
+ - name: 维护失败恢复浏览器回归
+ if: ${{ !cancelled() }}
+ run: node tests/maintenance-browser.cjs
typecho-integration:
runs-on: ubuntu-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7e52737..d5b0d7c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -64,8 +64,8 @@ jobs:
dist/infinitytime-plugin.zip
body_path: dist/RELEASE_NOTES.md
- # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.14.2。
- release-1-14-2:
+ # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.15.0。
+ release-1-15-0:
if: >-
github.event_name == 'workflow_run' &&
github.repository == 'Thregren/InfinityTime' &&
@@ -77,7 +77,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
- group: release-v1.14.2
+ group: release-v1.15.0
cancel-in-progress: false
steps:
- name: 在 checkout 前验证可信 main 和 CI 身份
diff --git a/README.md b/README.md
index 29d224c..58cda56 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,13 @@
## 功能特性
+### v1.15.0 维护恢复与省流浏览
+
+- 维护失败明细按图片或相册保留,支持每页50项查看与仅重试失败项。编码失败和字段同步失败分开恢复;旧任务未保存的明细不会被虚构。
+- 照片加载可跟随系统省流建议或手动切换。省流关闭自动翻页与邻图预取,仍可主动看图和加载更多;隐藏页面暂停额外加载并释放预取引用。
+- 重建后公开派生图带新的缓存标识,稳定照片ID与数据库物理路径保持不变。CDN必须将查询参数纳入缓存键;已打开页面仍需刷新获取新URL。
+- 管理员可查看只读健康检查和隔离恢复演练步骤。它不会自动修改目录、表引擎、服务器保护规则或生产数据;发现编码器不代表该工具已通过真实图片解码验证。
+
### v1.14.2 异常恢复与编辑一致性加固
分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本版在完整 CI 与发布校验通过后提供主题和插件更新包;不会自动部署线上站点。
@@ -243,4 +250,4 @@ git tag v1.7.0 && git push origin v1.7.0
### 本次固定版本发布
-1.14.2 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.14.2 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。
+1.15.0 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.15.0 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。
diff --git a/tests/admin-panel.php b/tests/admin-panel.php
index 5d883ab..7e53c8e 100644
--- a/tests/admin-panel.php
+++ b/tests/admin-panel.php
@@ -97,6 +97,7 @@ function _t($message): string { return $message; }
throw new RuntimeException('分页、明确发布或原生上传幂等字段缺失');
}
if (strpos($html, 'data-loaded="1"') !== false) { throw new RuntimeException('摘要首屏不应加载图片详情'); }
+ if (strpos($html, '只读健康检查') === false || strpos($html, '在隔离环境做一次恢复演练') === false) { throw new RuntimeException('管理员健康检查与恢复步骤缺失'); }
echo '后台模板:' . count($forms[1]) . " 个原生表单令牌、分页、草稿与按需详情检查通过\n";
} finally {
foreach (['header.php', 'menu.php', 'footer.php'] as $name) { unlink($adminDir . '/' . $name); }
diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php
index 533b39e..ac85754 100644
--- a/tests/admin-workflow.php
+++ b/tests/admin-workflow.php
@@ -76,6 +76,7 @@ public static function opt($name, $default) { return $default; }
namespace TypechoPlugin\InfinityTime\Lib {
function is_uploaded_file($path): bool { return is_file($path); }
class ImageRepository {
+ public static function uploadRoot(): string { return sys_get_temp_dir(); }
public static bool $locked = false;
public static bool $busy = false;
public static bool $failSync = false;
diff --git a/tests/data-saver.cjs b/tests/data-saver.cjs
new file mode 100644
index 0000000..97f8560
--- /dev/null
+++ b/tests/data-saver.cjs
@@ -0,0 +1,39 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const source = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/data-saver.js'), 'utf8');
+function run(saved, network, blocked = false) {
+ const listeners = {}, networkListeners = {}, changes = [];
+ const control = { value: '', parentNode: { hidden: true }, addEventListener(type, fn) { this[type] = fn; } };
+ const status = {};
+ const document = { hidden: false, getElementById: id => id.endsWith('-status') ? status : control };
+ const connection = network && { ...network, addEventListener(type, fn) { networkListeners[type] = fn; } };
+ const localStorage = { getItem() { if (blocked) throw Error('blocked'); return saved; }, setItem(key, value) { if (blocked) throw Error('blocked'); saved = value; } };
+ const window = { addEventListener(type, fn) { listeners[type] = fn; }, dispatchEvent(event) { changes.push(event.detail); } };
+ vm.runInNewContext(source, { document, window, navigator: { connection }, localStorage, CustomEvent: class { constructor(type, options) { this.detail = options.detail; } } });
+ return { window, document, control, status, changes, connection, listeners, networkListeners, select(value) { control.value = value; control.change(); } };
+}
+for (const network of [null, {}, { saveData: false, effectiveType: '4g' }]) {
+ const h = run(null, network);
+ assert.equal(h.window.InfinityDataSaver.isEnabled(), false);
+ assert.equal(h.control.parentNode.hidden, false);
+ h.select('on'); assert.equal(h.window.InfinityDataSaver.allowBackground(), false);
+ h.select('off'); assert.equal(h.window.InfinityDataSaver.allowBackground(), true);
+ h.document.hidden = true; assert.equal(h.window.InfinityDataSaver.allowBackground(), false);
+}
+for (const network of [{ saveData: true }, { effectiveType: '2g' }, { effectiveType: 'slow-2g' }]) {
+ const h = run('auto', network);
+ assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
+ h.select('off'); assert.equal(h.window.InfinityDataSaver.isEnabled(), false, '显式正常模式覆盖系统省流');
+ h.select('auto'); assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
+}
+const blocked = run('off', { saveData: true }, true);
+blocked.select('off'); assert.equal(blocked.window.InfinityDataSaver.isEnabled(), false, '存储拒绝不影响当前选择');
+const h = run('on', {});
+h.listeners.storage({ key: 'unrelated', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
+h.listeners.storage({ key: 'InfinityTime.dataSaver', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), false);
+h.connection.saveData = true; h.select('auto'); h.networkListeners.change(); assert.equal(h.window.InfinityDataSaver.isEnabled(), true);
+h.listeners.storage({ key: null, newValue: null }); assert.equal(h.control.value, 'auto');
+assert.equal(run('invalid', {}).control.value, 'auto');
+console.log('省流策略:系统建议、显式覆盖、存储拒绝、跨页同步和后台暂停通过');
diff --git a/tests/gallery-loading-browser.cjs b/tests/gallery-loading-browser.cjs
index c631f89..6514101 100644
--- a/tests/gallery-loading-browser.cjs
+++ b/tests/gallery-loading-browser.cjs
@@ -19,8 +19,8 @@ function card(id, cid, images, ids, options = {}) {
data-exif='[]' data-descs='[]' data-addresses='[]'>
Album ${id}
`;
}
function pageHtml(cards, next = '/page2', navigation = false) {
- return `
- ${['init', 'jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].concat(navigation ? ['gallery-navigation'] : []).map(n => ``).join('')}`;
+ return `
+ ${['data-saver', 'init', 'jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].concat(navigation ? ['gallery-navigation'] : []).map(n => ``).join('')}`;
}
const initial = card('a', 1, [url('a'), url('b'), url('pano'), url('slow')], ['p-1', 'p-2', 'p-3', 'p-4'], {
variants: [variants('a'), variants('b'), variants('pano')], panos: [0, 0, 1, 0]
@@ -100,10 +100,20 @@ const pixel = '