fix: 优化UI及兼容性策略

This commit is contained in:
eondrcode 2026-07-09 21:37:30 +08:00
parent 83ffa27eb4
commit 43c13c47df
11 changed files with 600 additions and 54 deletions

View file

@ -47,6 +47,8 @@ export interface PluginLayoutItem {
title?: string;
}
export type ReleaseCompatibilityMode = "compatible" | "all";
export interface BetaSource {
id: string;
repo: string;
@ -55,6 +57,7 @@ export interface BetaSource {
frozenVersion?: string;
includePrerelease?: boolean;
updateCheckMode?: "release" | "version";
compatibilityMode?: ReleaseCompatibilityMode;
updateDelayDays?: number;
autoUpdate: boolean;
enabled: boolean;
@ -63,8 +66,10 @@ export interface BetaSource {
latestPublishedAt?: string;
installedReleaseTag?: string;
installedReleasePublishedAt?: string;
installedReleaseIsGithubLatest?: boolean;
latestReleaseTag?: string;
latestReleasePublishedAt?: string;
latestReleaseIsGithubLatest?: boolean;
installedAt?: number;
lastChecked?: number;
error?: string;

View file

@ -62,6 +62,14 @@ export interface ReleaseVersion {
body?: string;
publishedAt?: string;
url?: string;
minAppVersion?: string;
manifestVersion?: string;
isGithubLatest?: boolean;
compatibilityChecked?: boolean;
}
export interface FetchReleaseVersionOptions {
includeManifest?: boolean;
}
const API_BASE = "https://api.github.com";
@ -164,6 +172,13 @@ const cloneReleaseVersions = (versions: ReleaseVersion[]): ReleaseVersion[] =>
const buildReleaseCacheKey = (repo: string, token?: string, proxy?: string): string =>
`${repo}\n${token ? "authenticated" : "anonymous"}\n${proxy || "direct"}`;
const buildReleaseCacheKeyWithOptions = (
repo: string,
token: string | undefined,
proxy: string | undefined,
options?: FetchReleaseVersionOptions
): string => `${buildReleaseCacheKey(repo, token, proxy)}\nmanifest:${options?.includeManifest ? "1" : "0"}`;
/**
* raw
*
@ -283,16 +298,77 @@ const upsertInstalledPluginRecord = (
manager.settings.Plugins.push(record);
};
const releaseToVersion = (release: ReleaseResponse, latestTag?: string): ReleaseVersion => ({
version: release.tag_name || "",
prerelease: Boolean(release.prerelease),
name: release.name || undefined,
body: release.body || undefined,
publishedAt: release.published_at || undefined,
url: release.html_url || undefined,
isGithubLatest: Boolean(latestTag && release.tag_name === latestTag),
});
const fetchReleaseManifestText = async (
manager: Manager,
repo: string,
release: ReleaseResponse,
token?: string
): Promise<string | null> => {
const manifestUrl = pickAsset(release, "manifest.json");
if (manifestUrl) {
try {
return await fetchText(manager, manifestUrl, token);
} catch {
// Fall back to the source tag below.
}
}
const tag = release.tag_name || "";
if (!tag) return null;
try {
return await fetchRawFromTag(manager, repo, tag, "manifest.json", token);
} catch {
return null;
}
};
const enrichReleaseVersionWithManifest = async (
manager: Manager,
repo: string,
release: ReleaseResponse,
version: ReleaseVersion,
token?: string
): Promise<ReleaseVersion> => {
const manifestText = await fetchReleaseManifestText(manager, repo, release, token);
if (!manifestText) return { ...version, compatibilityChecked: true };
try {
const manifest = JSON.parse(manifestText) as { version?: string; minAppVersion?: string };
return {
...version,
manifestVersion: typeof manifest.version === "string" ? manifest.version : undefined,
minAppVersion: typeof manifest.minAppVersion === "string" ? manifest.minAppVersion : undefined,
compatibilityChecked: true,
};
} catch {
return { ...version, compatibilityChecked: true };
}
};
/**
* release
*
* TTL
* GitHub API
*/
export const fetchReleaseVersions = async (manager: Manager, repoInput: string): Promise<ReleaseVersion[]> => {
export const fetchReleaseVersions = async (
manager: Manager,
repoInput: string,
options: FetchReleaseVersionOptions = {}
): Promise<ReleaseVersion[]> => {
const repo = sanitizeRepo(repoInput);
const token = await getToken(manager);
const cacheKey = buildReleaseCacheKey(repo, token, manager.settings.GITHUB_PROXY);
const cacheKey = buildReleaseCacheKeyWithOptions(repo, token, manager.settings.GITHUB_PROXY, options);
const cached = releaseVersionCache.get(cacheKey);
const now = Date.now();
@ -300,6 +376,8 @@ export const fetchReleaseVersions = async (manager: Manager, repoInput: string):
return cloneReleaseVersions(cached.versions);
}
const latestRelease = await getRelease(manager, repo, undefined, token).catch(() => null);
const latestTag = latestRelease?.tag_name || undefined;
const releases: ReleaseResponse[] = [];
for (let page = 1; ; page++) {
const url = `${API_BASE}/repos/${repo}/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`;
@ -309,17 +387,19 @@ export const fetchReleaseVersions = async (manager: Manager, repoInput: string):
if (pageReleases.length < RELEASES_PER_PAGE) break;
}
const versions = releases
let releaseEntries = releases
.filter((release) => !release.draft)
.map((release) => ({
version: release.tag_name || "",
prerelease: Boolean(release.prerelease),
name: release.name || undefined,
body: release.body || undefined,
publishedAt: release.published_at || undefined,
url: release.html_url || undefined,
}))
.filter((release) => release.version);
.map((release) => ({ release, version: releaseToVersion(release, latestTag) }))
.filter((entry) => entry.version.version);
if (options.includeManifest) {
releaseEntries = await Promise.all(releaseEntries.map(async (entry) => ({
release: entry.release,
version: await enrichReleaseVersionWithManifest(manager, repo, entry.release, entry.version, token),
})));
}
const versions = releaseEntries.map((entry) => entry.version);
releaseVersionCache.set(cacheKey, {
expiresAt: now + RELEASE_VERSION_CACHE_TTL_MS,

View file

@ -105,6 +105,13 @@ export default {
_选择版本_当前版本: 'Installed: {version}',
_选择版本_版本数量: '{count} versions',
_选择版本_选择版本: 'Select version {version}',
_策略: 'Compatibility',
_仅兼容: 'Compatible only',
_显示全部: 'Show all',
_GitHubLatest: 'GitHub Latest',
_需要版本: 'Requires Obsidian {version}',
_兼容: 'Compatible',
_不兼容: 'Incompatible',
_全部_描述: 'All',
_仅启用_描述: 'Enabled only',
@ -132,6 +139,10 @@ export default {
_说明: 'Choose how this overview check determines updates and delay days.',
_方式_标题: 'Detection method',
_方式_描述: 'Release order works for repositories with non-monotonic tags; version works for semantic versions.',
_兼容性_标题: 'Compatibility strategy',
_兼容性_描述: 'Uses manifest.json minAppVersion. Compatible first recommends versions that can run on this Obsidian version; show all lets latest and compatibility branches both participate.',
_兼容性_兼容优先: 'Recommend compatible only',
_兼容性_显示全部: 'Show all versions',
_延迟_标题: 'Delay days',
_延迟_描述: 'Only treat releases older than this many days as updates. 0 disables the delay.',
_开始按钮: 'Start check',

View file

@ -43,6 +43,10 @@ export default {
_说明: '选择本次总览检测的判定方式和延迟天数。',
_方式_标题: '检测方式',
_方式_描述: '发布顺序适合 tag 不严格递增的仓库;版本号适合规范语义化版本。',
_兼容性_标题: '兼容性策略',
_兼容性_描述: '按 manifest.json 的 minAppVersion 判断。兼容优先会推荐当前 Obsidian 可运行的版本;显示全部则允许最新版或兼容分支都参与判断。',
_兼容性_兼容优先: '只推荐兼容版本',
_兼容性_显示全部: '显示全部版本',
_延迟_标题: '延迟天数',
_延迟_描述: '只把发布满指定天数的版本视为可更新0 表示不延迟。',
_开始按钮: '开始检查',
@ -183,6 +187,13 @@ export default {
_选择版本_当前版本: '当前安装:{version}',
_选择版本_版本数量: '{count} 个版本',
_选择版本_选择版本: '选择版本 {version}',
_策略: '兼容性策略',
_仅兼容: '只推荐兼容',
_显示全部: '显示全部',
_GitHubLatest: 'GitHub 最新',
_需要版本: '需要 Obsidian {version}',
_兼容: '兼容',
_不兼容: '不兼容',
_ID已复制: 'ID已复制',

View file

@ -1,5 +1,5 @@
import { normalizePath, ObsidianProtocolData, Plugin, PluginManifest, Workspace } from 'obsidian';
import { DEFAULT_SETTINGS, ManagerSettings, PluginUpdateCheckMode } from './settings/data';
import { DEFAULT_SETTINGS, ManagerSettings, PluginUpdateCheckMode, ReleaseCompatibilityMode } from './settings/data';
import { ManagerSettingTab } from './settings';
import { Translator } from './lang/inxdex';
import { ManagerModal } from './modal/manager-modal';
@ -36,6 +36,7 @@ interface UpdateStatus {
interface PluginUpdateCheckOptions {
updateCheckMode?: PluginUpdateCheckMode;
compatibilityMode?: ReleaseCompatibilityMode;
updateDelayDays?: number;
}
@ -509,12 +510,14 @@ export default class Manager extends Plugin {
public getPluginUpdateCheckOptions(): Required<PluginUpdateCheckOptions> {
return {
updateCheckMode: this.normalizePluginUpdateCheckMode(this.settings.PLUGIN_UPDATE_CHECK_MODE),
compatibilityMode: this.normalizeReleaseCompatibilityMode(this.settings.PLUGIN_UPDATE_COMPATIBILITY_MODE),
updateDelayDays: this.normalizePluginUpdateDelayDays(this.settings.PLUGIN_UPDATE_DELAY_DAYS),
};
}
public async setPluginUpdateCheckOptions(options: PluginUpdateCheckOptions): Promise<void> {
this.settings.PLUGIN_UPDATE_CHECK_MODE = this.normalizePluginUpdateCheckMode(options.updateCheckMode);
this.settings.PLUGIN_UPDATE_COMPATIBILITY_MODE = this.normalizeReleaseCompatibilityMode(options.compatibilityMode);
this.settings.PLUGIN_UPDATE_DELAY_DAYS = this.normalizePluginUpdateDelayDays(options.updateDelayDays);
await this.saveSettings();
}
@ -589,7 +592,7 @@ export default class Manager extends Plugin {
private async checkBetaSource(source: BetaSource): Promise<void> {
try {
const versions = await fetchReleaseVersions(this, source.repo);
const versions = await fetchReleaseVersions(this, source.repo, { includeManifest: source.type === "plugin" });
let localVersion = source.localVersion || "";
if (source.type === "plugin") {
const pluginId = this.getBetaSourcePluginId(source);
@ -644,7 +647,7 @@ export default class Manager extends Plugin {
? source.latestReleasePublishedAt || source.latestPublishedAt
: undefined;
if (!targetVersion) {
const versions = await fetchReleaseVersions(this, source.repo);
const versions = await fetchReleaseVersions(this, source.repo, { includeManifest: source.type === "plugin" });
let localVersion = source.localVersion || "";
if (source.type === "plugin") {
const pluginId = this.getBetaSourcePluginId(source);
@ -1024,6 +1027,10 @@ export default class Manager extends Plugin {
return value === "version" ? "version" : "release";
}
private normalizeReleaseCompatibilityMode(value?: string | null): ReleaseCompatibilityMode {
return value === "all" ? "all" : "compatible";
}
private normalizePluginUpdateDelayDays(value: unknown): number {
const days = Math.floor(Number(value));
return Number.isFinite(days) && days > 0 ? days : 0;
@ -1032,6 +1039,7 @@ export default class Manager extends Plugin {
private resolvePluginUpdateCheckOptions(options?: PluginUpdateCheckOptions): Required<PluginUpdateCheckOptions> {
return {
updateCheckMode: this.normalizePluginUpdateCheckMode(options?.updateCheckMode ?? this.settings.PLUGIN_UPDATE_CHECK_MODE),
compatibilityMode: this.normalizeReleaseCompatibilityMode(options?.compatibilityMode ?? this.settings.PLUGIN_UPDATE_COMPATIBILITY_MODE),
updateDelayDays: this.normalizePluginUpdateDelayDays(options?.updateDelayDays ?? this.settings.PLUGIN_UPDATE_DELAY_DAYS),
};
}
@ -1060,6 +1068,7 @@ export default class Manager extends Plugin {
mode: "latest",
includePrerelease: false,
updateCheckMode: options.updateCheckMode,
compatibilityMode: options.compatibilityMode,
updateDelayDays: options.updateDelayDays || undefined,
autoUpdate: false,
enabled: true,
@ -1103,7 +1112,7 @@ export default class Manager extends Plugin {
st.repo = null;
}
if (st.repo) {
st.versions = await this.fetchGithubVersions(st.repo, updateOptions.updateCheckMode === "release");
st.versions = await this.fetchGithubVersions(st.repo, updateOptions.updateCheckMode === "release", true);
}
this.applyPluginUpdateStatus(st, pm, st.repo || null, st.versions || [], official, updateOptions);
if (this.settings.DEBUG) console.log("[BPM] update official match", pm.id, localVersion, "->", st.remoteVersion);
@ -1120,10 +1129,8 @@ export default class Manager extends Plugin {
if (repo) {
st.source = 'github';
st.repo = repo;
st.versions = await this.fetchGithubVersions(repo, updateOptions.updateCheckMode === "release");
// 选择一个默认的远端版本:优先最新稳定,否则第一个
const pick = st.versions?.find(v => !v.prerelease) ?? st.versions?.[0] ?? null;
const remoteVersion = pick?.version ?? await this.fetchGithubManifestVersion(repo);
st.versions = await this.fetchGithubVersions(repo, updateOptions.updateCheckMode === "release", true);
const remoteVersion = st.versions.length > 0 ? null : await this.fetchGithubManifestVersion(repo);
this.applyPluginUpdateStatus(st, pm, repo, st.versions || [], remoteVersion, updateOptions);
if (!st.remoteVersion) st.message = this.translator.t("更新_未获取到远端版本");
if (this.settings.DEBUG) console.log("[BPM] update github match", pm.id, repo, localVersion, "->", st.remoteVersion);
@ -1157,7 +1164,7 @@ export default class Manager extends Plugin {
st.source = "official";
try {
st.repo = await this.repoResolver.resolveRepo(pm.id);
if (st.repo) st.versions = await this.fetchGithubVersions(st.repo, updateOptions.updateCheckMode === "release");
if (st.repo) st.versions = await this.fetchGithubVersions(st.repo, updateOptions.updateCheckMode === "release", true);
} catch {
if (updateOptions.updateCheckMode === "release") throw new Error(this.translator.t("更新_未获取到远端版本"));
}
@ -1174,9 +1181,8 @@ export default class Manager extends Plugin {
if (repo) {
st.source = "github";
st.repo = repo;
st.versions = await this.fetchGithubVersions(repo, updateOptions.updateCheckMode === "release");
const pick = st.versions?.find(v => !v.prerelease) ?? st.versions?.[0] ?? null;
const remoteVersion = pick?.version ?? await this.fetchGithubManifestVersion(repo);
st.versions = await this.fetchGithubVersions(repo, updateOptions.updateCheckMode === "release", true);
const remoteVersion = st.versions.length > 0 ? null : await this.fetchGithubManifestVersion(repo);
this.applyPluginUpdateStatus(st, pm, repo, st.versions || [], remoteVersion, updateOptions);
if (!st.remoteVersion) st.message = this.translator.t("更新_未获取到远端版本");
if (this.settings.DEBUG) console.log("[BPM] single update github", pm.id, repo, localVersion, "->", st.remoteVersion);
@ -1262,9 +1268,9 @@ export default class Manager extends Plugin {
return null;
}
private async fetchGithubVersions(repoInput: string, throwOnError = false): Promise<ReleaseVersion[]> {
private async fetchGithubVersions(repoInput: string, throwOnError = false, includeManifest = false): Promise<ReleaseVersion[]> {
try {
return await fetchReleaseVersions(this, repoInput);
return await fetchReleaseVersions(this, repoInput, { includeManifest });
} catch (e) {
console.error("[BPM] fetchGithubVersions error", repoInput, e);
if (throwOnError) throw e;

View file

@ -49,7 +49,7 @@ import {
ManagerTransferTheme,
parseManagerTransferPackage,
} from "../import-export";
import { markSourceInstalledRelease, sourceHasUpdate as sourceHasConfiguredUpdate, syncSourceReleaseCheck } from "../source-release";
import { markSourceInstalledRelease, pickSourceTargetRelease, releaseIsCompatible, sourceHasUpdate as sourceHasConfiguredUpdate, syncSourceReleaseCheck } from "../source-release";
import {
createSharedVaultLinks,
forgetSharedVault,
@ -945,14 +945,26 @@ export class ManagerModal extends Modal {
return;
}
const versions = await fetchReleaseVersions(this.manager, repo);
const versions = await fetchReleaseVersions(this.manager, repo, { includeManifest: true });
if (!status) status = {};
status.repo = repo;
status.versions = versions;
status.error = "";
status.message = "";
const stableVersion = versions.find((release) => !release.prerelease)?.version;
status.remoteVersion = stableVersion || versions[0]?.version || status.remoteVersion || null;
const updateOptions = this.manager.getPluginUpdateCheckOptions();
const target = pickSourceTargetRelease({
id: pluginId,
repo,
type: "plugin",
mode: "latest",
includePrerelease: false,
updateCheckMode: updateOptions.updateCheckMode,
compatibilityMode: updateOptions.compatibilityMode,
updateDelayDays: updateOptions.updateDelayDays || undefined,
autoUpdate: false,
enabled: true,
}, versions);
status.remoteVersion = target?.tag || status.remoteVersion || null;
this.manager.updateStatus[pluginId] = {
...(this.manager.updateStatus?.[pluginId] ?? {}),
...status,
@ -4450,6 +4462,7 @@ export class ManagerModal extends Modal {
autoUpdate: source.autoUpdate ?? false,
mode: source.mode || "latest",
updateCheckMode: source.updateCheckMode || "release",
compatibilityMode: source.compatibilityMode || "compatible",
updateDelayDays: this.normalizeSourceUpdateDelayDays(source.updateDelayDays),
};
if (existing) {
@ -4461,7 +4474,7 @@ export class ManagerModal extends Modal {
private async checkBetaSource(source: BetaSource): Promise<BetaSource> {
try {
const versions = await fetchReleaseVersions(this.manager, source.repo);
const versions = await fetchReleaseVersions(this.manager, source.repo, { includeManifest: source.type === "plugin" });
const localVersion = this.getSourceLocalVersion(source);
const releaseCheck = syncSourceReleaseCheck(source, versions, localVersion);
await this.refreshSourcePackageCreatedAt(source);
@ -4725,6 +4738,8 @@ export class ManagerModal extends Modal {
const card = list.createDiv("manager-source-card");
card.setAttribute("data-source-repo", source.repo);
card.toggleClass("has-update", hasUpdate);
card.toggleClass("has-error", Boolean(source.error));
const cardMain = card.createDiv("manager-source-card__main");
const cardHeader = cardMain.createDiv("manager-source-card__header");
@ -4751,6 +4766,10 @@ export class ManagerModal extends Modal {
text: source.updateCheckMode === "version" ? t("来源_检测方式_版本号") : t("来源_检测方式_发布顺序"),
cls: `manager-source-item__chip ${source.updateCheckMode === "version" ? "is-latest" : "is-frozen"}`,
}));
chips.appendChild(createSpan({
text: source.compatibilityMode === "all" ? t("兼容性_显示全部") : t("兼容性_仅兼容"),
cls: `manager-source-item__chip ${source.compatibilityMode === "all" ? "is-frozen" : "is-latest"}`,
}));
if (source.autoUpdate) {
chips.appendChild(createSpan({ text: t("来源_自动更新"), cls: "manager-source-item__chip is-auto" }));
}
@ -4781,7 +4800,9 @@ export class ManagerModal extends Modal {
}
const controls = card.createDiv("manager-source-card__controls");
const strategyGroup = controls.createDiv("manager-source-card__control-group manager-source-card__control-group--strategy");
const strategyPanel = controls.createDiv("manager-source-card__control-panel manager-source-card__control-panel--strategy");
const updatePanel = controls.createDiv("manager-source-card__control-panel manager-source-card__control-panel--update");
const strategyGroup = strategyPanel.createDiv("manager-source-card__control-group manager-source-card__control-group--strategy");
strategyGroup.createSpan({ cls: "manager-source-card__control-label", text: t("来源_版本策略") });
const strategyWrap = strategyGroup.createDiv("manager-source-item__strategy");
const modeDropdown = new DropdownComponent(strategyWrap);
@ -4813,6 +4834,24 @@ export class ManagerModal extends Modal {
updateCheckDropdown.selectEl.addClass("manager-source-item__check-mode");
updateCheckDropdown.selectEl.setAttribute("aria-label", t("来源_检测方式"));
if (source.type === "plugin") {
const compatibilityGroup = strategyPanel.createDiv("manager-source-card__control-group manager-source-card__control-group--compatibility");
compatibilityGroup.createSpan({ cls: "manager-source-card__control-label", text: t("兼容性_策略") });
const compatibilityDropdown = new DropdownComponent(compatibilityGroup);
compatibilityDropdown.addOptions({
compatible: t("兼容性_仅兼容"),
all: t("兼容性_显示全部"),
});
compatibilityDropdown.setValue(source.compatibilityMode || "compatible");
compatibilityDropdown.onChange(async (value) => {
source.compatibilityMode = value === "all" ? "all" : "compatible";
await this.checkBetaSource(source);
this.renderContent();
});
compatibilityDropdown.selectEl.addClass("manager-source-item__mode");
compatibilityDropdown.selectEl.setAttribute("aria-label", t("兼容性_策略"));
}
const delayWrap = strategyWrap.createDiv("manager-source-item__delay");
delayWrap.createSpan({ cls: "manager-source-item__delay-label", text: t("来源_更新延迟_标签") });
const delayInput = new TextComponent(delayWrap);
@ -4846,7 +4885,8 @@ export class ManagerModal extends Modal {
});
}
const autoGroup = controls.createDiv("manager-source-card__control-group manager-source-card__control-group--auto");
const updateToggleGrid = updatePanel.createDiv("manager-source-card__toggle-grid");
const autoGroup = updateToggleGrid.createDiv("manager-source-card__control-group manager-source-card__control-group--auto");
autoGroup.createSpan({ cls: "manager-source-card__control-label", text: t("来源_更新") });
const autoWrap = autoGroup.createDiv("manager-source-item__toggle");
autoWrap.createSpan({ cls: "manager-source-item__toggle-label", text: t("来源_自动") });
@ -4859,7 +4899,7 @@ export class ManagerModal extends Modal {
this.renderContent();
});
const prereleaseGroup = controls.createDiv("manager-source-card__control-group manager-source-card__control-group--pre");
const prereleaseGroup = updateToggleGrid.createDiv("manager-source-card__control-group manager-source-card__control-group--pre");
prereleaseGroup.createSpan({ cls: "manager-source-card__control-label", text: t("安装_发布类型_预发布") });
const prereleaseWrap = prereleaseGroup.createDiv("manager-source-item__toggle");
prereleaseWrap.createSpan({ cls: "manager-source-item__toggle-label", text: t("来源_最新") });
@ -4873,7 +4913,7 @@ export class ManagerModal extends Modal {
this.renderContent();
});
const actionGroup = controls.createDiv("manager-source-card__actions");
const actionGroup = updatePanel.createDiv("manager-source-card__actions");
const prepareActionButton = (btn: ButtonComponent, label: string) => {
btn.setTooltip(label);
btn.buttonEl.setAttribute("aria-label", label);
@ -5056,6 +5096,9 @@ export class ManagerModal extends Modal {
};
const getReleaseOptionLabel = (release: ReleaseVersion) => [
release.version,
release.isGithubLatest ? t("兼容性_GitHubLatest") : "",
release.minAppVersion ? t("兼容性_需要版本", { version: release.minAppVersion }) : "",
release.minAppVersion ? t(releaseIsCompatible(release) ? "兼容性_兼容" : "兼容性_不兼容") : "",
formatReleaseDate(release.publishedAt),
release.prerelease ? t("安装_发布类型_预发布") : "",
].filter(Boolean).join(" · ");
@ -5063,6 +5106,21 @@ export class ManagerModal extends Modal {
if (this.installVersions.length === 0) return null;
const selected = this.installVersion.trim();
if (selected) return this.installVersions.find((item) => item.version === selected) ?? null;
if (this.installType === "plugin") {
const updateOptions = this.manager.getPluginUpdateCheckOptions();
const target = pickSourceTargetRelease({
id: this.getNormalizedInstallRepo(),
repo: this.getNormalizedInstallRepo(),
type: "plugin",
mode: "latest",
includePrerelease: false,
updateCheckMode: "release",
compatibilityMode: updateOptions.compatibilityMode,
autoUpdate: false,
enabled: true,
}, this.installVersions);
if (target) return this.installVersions.find((item) => item.version === target.tag) ?? null;
}
return this.installVersions.find((item) => !item.prerelease) || this.installVersions[0];
};
const updateReleaseInfo = () => {
@ -5081,6 +5139,9 @@ export class ManagerModal extends Modal {
releaseEls.title.setText(release.name || release.version);
const metaParts = [
release.version,
release.isGithubLatest ? t("兼容性_GitHubLatest") : "",
release.minAppVersion ? t("兼容性_需要版本", { version: release.minAppVersion }) : "",
release.minAppVersion ? t(releaseIsCompatible(release) ? "兼容性_兼容" : "兼容性_不兼容") : "",
release.prerelease ? t("安装_发布类型_预发布") : t("安装_发布类型_正式版"),
formatReleaseDate(release.publishedAt),
].filter(Boolean);
@ -5096,7 +5157,7 @@ export class ManagerModal extends Modal {
fetchButton?.setDisabled(true);
fetchButton?.setButtonText(t("管理器_安装_版本_获取中"));
try {
this.installVersions = await fetchReleaseVersions(this.manager, validRepo);
this.installVersions = await fetchReleaseVersions(this.manager, validRepo, { includeManifest: this.installType === "plugin" });
this.installVersion = "";
if (this.installVersions.length === 0) new Notice(t("管理器_安装_版本_空提示"));
} catch (e) {
@ -5277,10 +5338,23 @@ export class ManagerModal extends Modal {
let selectedRelease = getSelectedRelease();
if (!selectedRelease) {
try {
const versions = await fetchReleaseVersions(this.manager, validRepo);
const updateOptions = this.manager.getPluginUpdateCheckOptions();
const versions = await fetchReleaseVersions(this.manager, validRepo, { includeManifest: this.installType === "plugin" });
selectedRelease = this.installVersion
? versions.find((item) => item.version === this.installVersion) ?? null
: versions.find((item) => !item.prerelease) || versions[0] || null;
: this.installType === "plugin"
? versions.find((item) => item.version === pickSourceTargetRelease({
id: pluginId || validRepo,
repo: validRepo,
type: "plugin",
mode: "latest",
includePrerelease: false,
updateCheckMode: "release",
compatibilityMode: updateOptions.compatibilityMode,
autoUpdate: false,
enabled: true,
}, versions)?.tag) || versions.find((item) => !item.prerelease) || versions[0] || null
: versions.find((item) => !item.prerelease) || versions[0] || null;
} catch {
selectedRelease = null;
}
@ -5298,6 +5372,7 @@ export class ManagerModal extends Modal {
frozenVersion: this.installVersion || undefined,
includePrerelease: Boolean(selectedRelease?.prerelease),
updateCheckMode: "release",
compatibilityMode: this.manager.getPluginUpdateCheckOptions().compatibilityMode,
autoUpdate: false,
enabled: true,
localVersion,
@ -6825,14 +6900,14 @@ export class ManagerModal extends Modal {
private applyEditingStyle() {
if (!this.modalContainer) return;
if (this.editorMode) {
this.modalContainer.addClass("manager-container--editing");
this.modalContainer.addClass("manager-container--editing");
} else {
this.modalContainer.removeClass("manager-container--editing");
this.modalContainer.removeClass("manager-container--editing");
}
if (this.bulkEditMode) {
this.modalContainer.addClass("manager-container--bulk-editing");
this.modalContainer.addClass("manager-container--bulk-editing");
} else {
this.modalContainer.removeClass("manager-container--bulk-editing");
this.modalContainer.removeClass("manager-container--bulk-editing");
}
if (this.desktopActionWrapper) this.syncPageChrome();
}

View file

@ -9,10 +9,11 @@ import {
} from "obsidian";
import Manager from "main";
import type { PluginUpdateCheckMode } from "../settings/data";
import type { PluginUpdateCheckMode, ReleaseCompatibilityMode } from "../settings/data";
export interface PluginUpdateCheckConfig {
updateCheckMode: PluginUpdateCheckMode;
compatibilityMode: ReleaseCompatibilityMode;
updateDelayDays: number;
}
@ -24,6 +25,9 @@ export const normalizePluginUpdateDelayDays = (value: unknown): number => {
export const normalizePluginUpdateCheckMode = (value: unknown): PluginUpdateCheckMode =>
value === "version" ? "version" : "release";
export const normalizeReleaseCompatibilityMode = (value: unknown): ReleaseCompatibilityMode =>
value === "all" ? "all" : "compatible";
export const openPluginUpdateCheckModal = (
app: App,
manager: Manager,
@ -46,6 +50,7 @@ class PluginUpdateCheckModal extends Modal {
super(app);
this.config = {
updateCheckMode: normalizePluginUpdateCheckMode(initial.updateCheckMode),
compatibilityMode: normalizeReleaseCompatibilityMode(initial.compatibilityMode),
updateDelayDays: normalizePluginUpdateDelayDays(initial.updateDelayDays),
};
}
@ -83,6 +88,21 @@ class PluginUpdateCheckModal extends Modal {
});
});
new Setting(body)
.setName(t("更新检测配置_兼容性_标题"))
.setDesc(t("更新检测配置_兼容性_描述"))
.addDropdown((dropdown: DropdownComponent) => {
dropdown.addOptions({
compatible: t("更新检测配置_兼容性_兼容优先"),
all: t("更新检测配置_兼容性_显示全部"),
});
dropdown.setValue(this.config.compatibilityMode);
dropdown.selectEl.setAttribute("aria-label", t("更新检测配置_兼容性_标题"));
dropdown.onChange((value) => {
this.config.compatibilityMode = normalizeReleaseCompatibilityMode(value);
});
});
new Setting(body)
.setName(t("更新检测配置_延迟_标题"))
.setDesc(t("更新检测配置_延迟_描述"))
@ -127,6 +147,7 @@ class PluginUpdateCheckModal extends Modal {
if (this.resolved) return;
this.resolved = true;
this.config.updateDelayDays = normalizePluginUpdateDelayDays(this.config.updateDelayDays);
this.config.compatibilityMode = normalizeReleaseCompatibilityMode(this.config.compatibilityMode);
this.resolve({ ...this.config });
this.close();
}

View file

@ -2,6 +2,7 @@ import { App, ExtraButtonComponent, Modal, Notice, setIcon, Setting } from "obsi
import Manager from "main";
import { ReleaseVersion, fetchReleaseVersions } from "src/github-install";
import { getExtraButtonElement } from "src/obsidian-internals";
import { pickSourceTargetRelease, releaseIsCompatible } from "src/source-release";
export class UpdateModal extends Modal {
private manager: Manager;
@ -75,7 +76,7 @@ export class UpdateModal extends Modal {
const loading = this.versionListEl.createDiv("manager-version-picker__empty");
loading.setText(t("管理器_选择版本_获取中"));
try {
versionList = await fetchReleaseVersions(this.manager, this.repo);
versionList = await fetchReleaseVersions(this.manager, this.repo, { includeManifest: true });
} catch (e) {
console.error("fetch versions in modal failed", e);
new Notice(t("管理器_选择版本_获取失败提示"), 4000);
@ -87,11 +88,22 @@ export class UpdateModal extends Modal {
const hasDefaultVersion = Boolean(this.defaultVersion && versionList.some((release) => release.version === this.defaultVersion));
const hasLocalVersion = Boolean(manifest?.version && versionList.some((release) => release.version === manifest.version));
const updateOptions = this.manager.getPluginUpdateCheckOptions();
const target = this.repo ? pickSourceTargetRelease({
id: this.pluginId,
repo: this.repo,
type: "plugin",
mode: "latest",
includePrerelease: false,
updateCheckMode: updateOptions.updateCheckMode,
compatibilityMode: updateOptions.compatibilityMode,
updateDelayDays: updateOptions.updateDelayDays || undefined,
autoUpdate: false,
enabled: true,
}, versionList) : null;
this.selectedVersion = hasDefaultVersion
? this.defaultVersion!
: hasLocalVersion
? manifest.version
: (versionList[0]?.version ?? "");
: target?.tag || (hasLocalVersion ? manifest.version : (versionList[0]?.version ?? ""));
summaryStats.empty();
const releaseCount = summaryStats.createSpan({ cls: "manager-version-picker__summary-stat" });
setIcon(releaseCount.createSpan({ cls: "manager-version-picker__summary-stat-icon" }), "tags");
@ -154,13 +166,23 @@ export class UpdateModal extends Modal {
if (release.version === localVersion) {
title.createSpan({ cls: "manager-version-picker__badge", text: t("来源_当前") });
}
if (release.isGithubLatest) {
title.createSpan({ cls: "manager-version-picker__badge is-latest", text: t("兼容性_GitHubLatest") });
}
title.createSpan({
cls: `manager-version-picker__badge ${release.prerelease ? "is-prerelease" : "is-stable"}`,
text: t(release.prerelease ? "安装_发布类型_预发布" : "安装_发布类型_正式版"),
});
if (release.minAppVersion) {
title.createSpan({
cls: `manager-version-picker__badge ${releaseIsCompatible(release) ? "is-compatible" : "is-incompatible"}`,
text: t(releaseIsCompatible(release) ? "兼容性_兼容" : "兼容性_不兼容"),
});
}
const meta = main.createDiv("manager-version-picker__item-meta");
if (release.name && release.name !== release.version) meta.createSpan({ text: release.name });
if (release.minAppVersion) meta.createSpan({ text: t("兼容性_需要版本", { version: release.minAppVersion }) });
if (release.publishedAt) meta.createSpan({ text: this.formatDate(release.publishedAt) });
const check = item.createSpan({ cls: "manager-version-picker__item-check" });

View file

@ -25,6 +25,7 @@ export type FilterOperator = "contains" | "not-contains";
export type TagFilterOperator = FilterOperator;
export type PluginOverviewLayout = "list" | "two-column";
export type PluginUpdateCheckMode = NonNullable<BetaSource["updateCheckMode"]>;
export type ReleaseCompatibilityMode = NonNullable<BetaSource["compatibilityMode"]>;
export const DEFAULT_MAIN_PAGE_ACTION_PLACEMENT: Record<MainPageActionId, MainPageActionPlacement> = {
checkUpdate: "menu",
@ -83,6 +84,8 @@ export interface ManagerSettings {
SOURCE_AUTO_UPDATE: boolean;
/** 插件总览更新检测的判定方式。release 按 GitHub 发布顺序version 按版本号比较。 */
PLUGIN_UPDATE_CHECK_MODE: PluginUpdateCheckMode;
/** 插件总览更新检测是否按 manifest.minAppVersion 过滤不兼容版本。 */
PLUGIN_UPDATE_COMPATIBILITY_MODE: ReleaseCompatibilityMode;
/** 插件总览更新检测延迟天数;只把发布满指定天数的版本视为可更新。 */
PLUGIN_UPDATE_DELAY_DAYS: number;
/** GitHub 请求中转站;留空时直连官方 GitHub。支持前缀或 {url}/{encodedUrl} 模板。 */
@ -188,6 +191,7 @@ export const DEFAULT_SETTINGS: ManagerSettings = {
SOURCE_STARTUP_CHECK_UPDATES: false,
SOURCE_AUTO_UPDATE: true,
PLUGIN_UPDATE_CHECK_MODE: "release",
PLUGIN_UPDATE_COMPATIBILITY_MODE: "compatible",
PLUGIN_UPDATE_DELAY_DAYS: 0,
GITHUB_PROXY: "",
GITHUB_TOKEN: "",

View file

@ -1,3 +1,4 @@
import { requireApiVersion } from "obsidian";
import type { BetaSource } from "./data/types";
import type { ReleaseVersion } from "./github-install";
@ -6,6 +7,7 @@ export interface SourceReleaseRef {
publishedAt?: string;
prerelease?: boolean;
index?: number;
isGithubLatest?: boolean;
}
export interface SourceReleaseCheck {
@ -35,6 +37,7 @@ const releaseToRef = (release: ReleaseVersion, index: number): SourceReleaseRef
publishedAt: release.publishedAt,
prerelease: release.prerelease,
index,
isGithubLatest: release.isGithubLatest,
});
const DAY_MS = 24 * 60 * 60 * 1000;
@ -61,6 +64,16 @@ const releaseIsMatureEnough = (source: BetaSource, release: ReleaseVersion, now
return publishedAt <= now - delayDays * DAY_MS;
};
export const releaseIsCompatible = (release: ReleaseVersion): boolean => {
const minAppVersion = release.minAppVersion?.trim();
if (!minAppVersion) return true;
try {
return requireApiVersion(minAppVersion);
} catch {
return true;
}
};
const sourceTargetIsMatureEnough = (source: BetaSource): boolean => {
const delayDays = normalizeUpdateDelayDays(source);
if (delayDays <= 0) return true;
@ -91,8 +104,16 @@ export const pickSourceTargetRelease = (source: BetaSource, versions: ReleaseVer
};
}
const release = (source.includePrerelease ? versions : versions.filter((item) => !item.prerelease))
.find((item) => releaseIsMatureEnough(source, item));
const compatibleVersions = source.compatibilityMode === "all"
? versions
: versions.filter((item) => releaseIsCompatible(item));
const candidates = source.includePrerelease
? compatibleVersions
: compatibleVersions.filter((item) => !item.prerelease);
const githubLatest = candidates.find((item) => item.isGithubLatest);
const release = githubLatest && releaseIsMatureEnough(source, githubLatest)
? githubLatest
: candidates.find((item) => releaseIsMatureEnough(source, item));
if (!release) return null;
return releaseToRef(release, versions.indexOf(release));
};
@ -136,11 +157,13 @@ export const syncSourceReleaseCheck = (
source.localVersion = localVersion || source.localVersion || "";
source.latestReleaseTag = target?.tag || "";
source.latestReleasePublishedAt = target?.publishedAt;
source.latestReleaseIsGithubLatest = target?.isGithubLatest;
source.latestVersion = target?.tag || "";
source.latestPublishedAt = target?.publishedAt;
if (installed?.tag) source.installedReleaseTag = installed.tag;
if (installed?.publishedAt) source.installedReleasePublishedAt = installed.publishedAt;
source.installedReleaseIsGithubLatest = installed?.isGithubLatest;
return { target, installed };
};
@ -154,6 +177,9 @@ export const markSourceInstalledRelease = (
const tag = cleanReleaseTag(releaseTag);
if (tag) source.installedReleaseTag = tag;
source.installedReleasePublishedAt = publishedAt || source.installedReleasePublishedAt;
if (releaseTagsMatch(tag, source.latestReleaseTag)) {
source.installedReleaseIsGithubLatest = source.latestReleaseIsGithubLatest;
}
source.localVersion = localVersion || source.localVersion || tag;
};
@ -197,6 +223,7 @@ export const sourceHasReleaseUpdate = (source: BetaSource): boolean => {
if (!targetTag || !installedTag) return false;
if (releaseTagsMatch(targetTag, installedTag)) return false;
if (!sourceTargetIsMatureEnough(source)) return false;
if (source.compatibilityMode !== "all" && source.installedReleaseIsGithubLatest && !source.latestReleaseIsGithubLatest) return false;
if (source.mode === "frozen") return true;

View file

@ -9480,6 +9480,19 @@ body.theme-dark {
color: var(--color-green);
}
.manager-version-picker__badge.is-compatible,
.manager-version-picker__badge.is-latest {
border-color: color-mix(in srgb, var(--interactive-accent) 42%, var(--bpm-border));
background: color-mix(in srgb, var(--interactive-accent) 10%, var(--bpm-card));
color: var(--interactive-accent);
}
.manager-version-picker__badge.is-incompatible {
border-color: color-mix(in srgb, var(--text-error) 48%, var(--bpm-border));
background: color-mix(in srgb, var(--text-error) 10%, var(--bpm-card));
color: var(--text-error);
}
.manager-version-picker__item-meta {
display: flex;
align-items: center;
@ -9905,11 +9918,11 @@ body.theme-dark {
}
}
@media (max-width: 520px) {
.manager-header {
height: var(--bpm-ui-header-height);
min-height: var(--bpm-ui-header-height);
padding-right: 12px;
@media (max-width: 520px) {
.manager-header {
height: var(--bpm-ui-header-height);
min-height: var(--bpm-ui-header-height);
padding-right: 12px;
padding-left: 12px;
}
@ -9921,6 +9934,277 @@ body.theme-dark {
}
}
/* Source subscription cards: separated identity, status, configuration, and actions. */
.manager-source-card {
position: relative;
gap: 12px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--bpm-border) 70%, transparent);
background: color-mix(in srgb, var(--bpm-card) 94%, var(--bpm-surface));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.manager-source-card::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
background: transparent;
pointer-events: none;
}
.manager-source-card:hover {
border-color: color-mix(in srgb, var(--text-muted) 28%, var(--bpm-border));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
}
.manager-source-card.has-update {
border-color: color-mix(in srgb, var(--color-green) 24%, var(--bpm-border));
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-green) 5%, var(--bpm-card)) 0%,
var(--bpm-card) 22%
);
}
.manager-source-card.has-update::before {
background: color-mix(in srgb, var(--color-green) 72%, var(--bpm-border));
}
.manager-source-card.has-error {
border-color: color-mix(in srgb, var(--text-error) 28%, var(--bpm-border));
background: linear-gradient(
90deg,
color-mix(in srgb, var(--text-error) 5%, var(--bpm-card)) 0%,
var(--bpm-card) 22%
);
}
.manager-source-card.has-error::before {
background: color-mix(in srgb, var(--text-error) 74%, var(--bpm-border));
}
.manager-source-card__header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, auto);
align-items: start;
gap: 12px;
}
.manager-source-card__title-block,
.manager-source-card__repo-line {
min-width: 0;
}
.manager-source-card__repo {
font-size: 14px;
line-height: 1.25;
}
.manager-source-card__chips {
justify-content: flex-end;
align-content: flex-start;
max-width: 430px;
}
.manager-source-card__meta-grid {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
.manager-source-card__meta {
grid-template-columns: 16px minmax(0, 1fr);
grid-template-areas:
"icon label"
"icon value";
align-items: center;
gap: 3px 7px;
min-height: 42px;
padding: 7px 8px;
border-color: color-mix(in srgb, var(--bpm-border) 46%, transparent);
background: color-mix(in srgb, var(--bpm-muted) 34%, transparent);
}
.manager-source-card__meta-icon {
grid-area: icon;
align-self: center;
}
.manager-source-card__meta-label {
grid-area: label;
line-height: 1.1;
}
.manager-source-card__meta-value {
grid-area: value;
line-height: 1.25;
}
.manager-source-card__controls {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(230px, 0.65fr);
align-items: stretch;
gap: 10px;
padding-top: 10px;
}
.manager-source-card__control-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 9px;
border: 0;
border-radius: var(--bpm-radius-sm);
background: color-mix(in srgb, var(--bpm-muted) 34%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bpm-border) 42%, transparent);
}
.manager-source-card__control-panel--update {
justify-content: space-between;
}
.manager-source-card__control-group,
.manager-source-card__control-group--strategy,
.manager-source-card__control-group--compatibility,
.manager-source-card__control-group--auto {
display: flex;
align-items: center;
flex: 0 1 auto;
flex-wrap: wrap;
gap: 7px;
width: 100%;
min-width: 0;
}
.manager-source-card__control-group--strategy,
.manager-source-card__control-group--compatibility {
align-items: stretch;
flex-direction: column;
}
.manager-source-card__control-group--compatibility .manager-source-item__mode {
flex: 1 1 auto;
width: 100%;
}
.manager-source-card__control-label {
flex: 0 0 auto;
white-space: normal;
line-height: 1.2;
}
.manager-source-card__toggle-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
min-width: 0;
}
.manager-source-card__control-group--auto,
.manager-source-card__control-group--pre {
align-items: stretch;
flex-direction: column;
}
.manager-source-item__strategy {
align-items: center;
flex: 1 1 auto;
gap: 7px;
width: 100%;
}
.manager-source-item__mode,
.manager-source-item__version {
flex: 1 1 124px;
width: auto;
min-width: 118px;
}
.manager-source-item__check-mode {
min-width: 132px;
}
.manager-source-item__delay {
flex: 1 1 132px;
justify-content: space-between;
min-width: 128px;
}
.manager-source-item__delay-input {
flex: 0 0 58px;
width: 58px;
}
.manager-source-item__toggle {
justify-content: space-between;
width: 100%;
}
.manager-source-card__actions {
align-self: stretch;
display: grid;
grid-template-columns: repeat(5, minmax(34px, 1fr));
gap: 6px;
justify-content: stretch;
padding-top: 8px;
border-top: 1px solid color-mix(in srgb, var(--bpm-border) 44%, transparent);
}
.manager-source-card__actions button {
width: 100%;
min-width: 0;
}
.manager-source-card__actions button:disabled {
opacity: 0.45;
}
@media (max-width: 760px) {
.manager-source-card__header,
.manager-source-card__controls {
display: grid;
grid-template-columns: 1fr;
}
.manager-source-card__chips {
justify-content: flex-start;
max-width: none;
}
.manager-source-card__meta-grid {
grid-template-columns: 1fr;
}
.manager-source-card__toggle-grid {
grid-template-columns: 1fr;
}
.manager-source-card__actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(44px, 1fr));
justify-content: stretch;
}
.manager-source-card__actions button {
width: 100%;
}
.manager-source-item__mode,
.manager-source-item__version,
.manager-source-item__delay {
flex: 1 1 148px;
width: auto;
min-width: 0;
}
.manager-source-item__delay-input {
flex: 0 0 58px;
width: 58px;
}
}
/* Final uninstall modal overrides after legacy delete rules. */
.manager-delete__title {
display: flex;