From 597f429bcb48af6fbc84196a37c1a311fb78cb8c Mon Sep 17 00:00:00 2001 From: Northword Date: Fri, 17 Apr 2026 21:01:12 +0800 Subject: [PATCH] stage --- src/cli.ts | 12 ++- src/core/builder/manifest.ts | 37 ++++++++ src/core/manifest-command.ts | 93 ++++++++++++++++++++ src/index.ts | 3 +- src/utils/zotero-version.test.ts | 48 +++++++++++ src/utils/zotero-version.ts | 142 +++++++++++++++++++++++++++++++ 6 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 src/core/manifest-command.ts create mode 100644 src/utils/zotero-version.test.ts create mode 100644 src/utils/zotero-version.ts diff --git a/src/cli.ts b/src/cli.ts index 9231ed2..3e22610 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import type { Context, OverrideConfig } from "./types/index.js"; import process from "node:process"; import { Command } from "commander"; import { name, version } from "../package.json" with { type: "json" }; -import { Build, Config, Release, Serve, Test } from "./index.js"; +import { Build, Config, ManifestCommand, Release, Serve, Test } from "./index.js"; import { checkGitIgnore } from "./utils/gitignore.js"; import { logger } from "./utils/logger.js"; import { updateNotifier } from "./utils/updater.js"; @@ -92,6 +92,16 @@ async function main() { }); }); + cli + .command("manifest:update-max-version") + .description("Update strict_max_version in manifest.json") + .argument("[path]", "Path to manifest.json file") + .action(async (path) => { + const ctx = await Config.loadConfig({}); + const instance = new ManifestCommand(ctx); + await instance.updateMaxVersion(path); + }); + cli.arguments("").action((cmd) => { cli.outputHelp(); logger.error(`Unknown command name "${cmd}".`); diff --git a/src/core/builder/manifest.ts b/src/core/builder/manifest.ts index 1bc266c..05b4d28 100644 --- a/src/core/builder/manifest.ts +++ b/src/core/builder/manifest.ts @@ -3,6 +3,39 @@ import type { Manifest } from "../../types/manifest.js"; import { toMerged } from "es-toolkit"; import { outputJSON, pathExists, readJSON } from "fs-extra/esm"; import { logger } from "../../utils/logger.js"; +import { compareVersions, getZoteroVersionInfo, parseMajorVersion } from "../../utils/zotero-version.js"; + +/** + * Check and update strict_max_version in manifest + */ +async function checkStrictMaxVersion(manifest: Manifest): Promise { + try { + const versionInfo = await getZoteroVersionInfo(); + const latestBeta = versionInfo.beta.mac; // All platforms have the same version + const latestRelease = versionInfo.release.mac; + + const currentMaxVersion = manifest.applications?.zotero?.strict_max_version; + + if (!currentMaxVersion) { + // Auto-fill with latest beta major version + 1 + const majorVersion = parseMajorVersion(latestBeta); + const autoFilledVersion = `${majorVersion + 1}.0`; + manifest.applications.zotero.strict_max_version = autoFilledVersion; + logger.info(`Auto-filled strict_max_version to ${autoFilledVersion} (latest beta: ${latestBeta})`); + } + else { + // Check if current version is outdated + if (compareVersions(currentMaxVersion, latestRelease) < 0) { + logger.warn(`strict_max_version (${currentMaxVersion}) is older than the latest Zotero release (${latestRelease})`); + logger.warn(`To update, run: npx zotero-plugin manifest:update-max-version`); + } + } + } + catch (error) { + logger.debug(`Failed to check Zotero version: ${error}`); + // Don't fail the build if version check fails + } +} export default async function buildManifest(ctx: Context): Promise { if (!ctx.build.makeManifest.enable) @@ -28,6 +61,10 @@ export default async function buildManifest(ctx: Context): Promise { }; const data: Manifest = toMerged(userData, template); + + // Check and update strict_max_version + await checkStrictMaxVersion(data); + logger.debug(`manifest: ${JSON.stringify(data, null, 2)}`); outputJSON(manifestPath, data, { spaces: 2 }); diff --git a/src/core/manifest-command.ts b/src/core/manifest-command.ts new file mode 100644 index 0000000..c252e63 --- /dev/null +++ b/src/core/manifest-command.ts @@ -0,0 +1,93 @@ +import type { Manifest } from "../types/manifest.js"; +import { basename } from "node:path"; +import process from "node:process"; +import { pathExists, readJSON, writeJSON } from "fs-extra/esm"; +import { getZoteroVersionInfo, parseMajorVersion } from "../utils/zotero-version.js"; +import { Base } from "./base.js"; + +export default class ManifestCommand extends Base { + /** + * Update strict_max_version in manifest.json + */ + async updateMaxVersion(manifestPath?: string): Promise { + // Determine the manifest file path + let targetPath: string | null = null; + if (manifestPath) { + targetPath = manifestPath; + } + else { + // Search for manifest.json in common locations + const possiblePaths = [ + "manifest.json", + "src/manifest.json", + "addon/manifest.json", + ]; + + for (const p of possiblePaths) { + if (await pathExists(p)) { + targetPath = p; + break; + } + } + + if (!targetPath) { + this.logger.error("manifest.json not found in current directory or common locations"); + this.logger.error("Please specify the path to manifest.json"); + process.exit(1); + } + } + + if (!await pathExists(targetPath)) { + this.logger.error(`manifest.json not found at ${targetPath}`); + process.exit(1); + } + + try { + const manifest = await readJSON(targetPath) as Manifest; + + // Fetch latest version info + const versionInfo = await getZoteroVersionInfo(); + const latestBeta = versionInfo.beta.mac; + + // Calculate new version + const majorVersion = parseMajorVersion(latestBeta); + const newMaxVersion = `${majorVersion + 1}.0`; + + // Update manifest + if (!manifest.applications) { + manifest.applications = { zotero: { id: "", update_url: "" } }; + } + if (!manifest.applications.zotero) { + manifest.applications.zotero = { id: "", update_url: "" }; + } + + const oldMaxVersion = manifest.applications.zotero.strict_max_version; + manifest.applications.zotero.strict_max_version = newMaxVersion; + + // Write back to file + await writeJSON(targetPath, manifest, { spaces: 2 }); + + this.logger.success(`Updated strict_max_version in ${basename(targetPath)}`); + if (oldMaxVersion) { + this.logger.info(` ${oldMaxVersion} -> ${newMaxVersion}`); + } + else { + this.logger.info(` Added: ${newMaxVersion}`); + } + this.logger.info(`Latest Zotero beta: ${latestBeta}`); + } + catch (error) { + this.logger.error(`Failed to update manifest: ${error}`); + process.exit(1); + } + } + + async run(): Promise { + // This is a no-op, subcommands should be called directly + this.logger.info("Use 'zotero-plugin manifest:update-max-version' to update strict_max_version"); + } + + exit(): void { + // No cleanup needed + } +} diff --git a/src/index.ts b/src/index.ts index 013465e..9a3a8e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { defineConfig, loadConfig } from "./config.js"; import Build from "./core/builder/index.js"; +import ManifestCommand from "./core/manifest-command.js"; import Release from "./core/releaser/index.js"; import Serve from "./core/server.js"; import Test from "./core/tester/index.js"; @@ -12,4 +13,4 @@ const Config: { loadConfig, }; -export { Build, Config, defineConfig, Release, Serve, Test }; +export { Build, Config, defineConfig, ManifestCommand, Release, Serve, Test }; diff --git a/src/utils/zotero-version.test.ts b/src/utils/zotero-version.test.ts new file mode 100644 index 0000000..1ef0934 --- /dev/null +++ b/src/utils/zotero-version.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { compareVersions, parseMajorVersion } from "./zotero-version.js"; + +describe("zotero-version utilities", () => { + describe("parseMajorVersion", () => { + it("should parse beta version", () => { + expect(parseMajorVersion("9.0-beta.21+1a89239a1")).toBe(9); + }); + + it("should parse release version", () => { + expect(parseMajorVersion("9.0")).toBe(9); + }); + + it("should handle single digit version", () => { + expect(parseMajorVersion("10.0")).toBe(10); + }); + + it("should throw on invalid format", () => { + expect(() => parseMajorVersion("invalid")).toThrow("Invalid version format"); + }); + }); + + describe("compareVersions", () => { + it("should return -1 when v1 < v2", () => { + expect(compareVersions("8.0", "9.0")).toBe(-1); + }); + + it("should return 0 when v1 === v2", () => { + expect(compareVersions("9.0", "9.0")).toBe(0); + }); + + it("should return 1 when v1 > v2", () => { + expect(compareVersions("9.0", "8.0")).toBe(1); + }); + + it("should handle beta versions", () => { + expect(compareVersions("9.0-beta.20", "9.0-beta.21")).toBe(-1); + }); + + it("should handle mixed version formats", () => { + expect(compareVersions("9.0", "9.0-beta.21")).toBe(-1); + }); + + it("should handle different lengths", () => { + expect(compareVersions("9", "9.0.1")).toBe(-1); + }); + }); +}); diff --git a/src/utils/zotero-version.ts b/src/utils/zotero-version.ts new file mode 100644 index 0000000..6784dd4 --- /dev/null +++ b/src/utils/zotero-version.ts @@ -0,0 +1,142 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { ensureDir, pathExists, readJSON, writeJSON } from "fs-extra/esm"; + +/** + * Platform identifier for version information + */ +export type Platform = "mac" | "win-x64" | "win-arm64" | "win32" | "linux-x86_64" | "linux-i686" | "linux-arm64"; + +/** + * Version info structure + */ +export interface ZoteroVersionInfo { + lastUpdated: number; + release: Record; + beta: Record; +} + +const CACHE_DIR = join(homedir(), ".scaffold", "cache"); +const CACHE_FILE = join(CACHE_DIR, "zotero-version.json"); +const CACHE_TTL = 3 * 24 * 60 * 60 * 1000; // 3 days in milliseconds +const API_BETA = "https://www.zotero.org/download/client/version?channel=beta"; +const API_RELEASE = "https://www.zotero.org/download/client/version?channel=release"; + +/** + * Fetch Zotero version information from API + */ +async function fetchVersionsFromAPI(): Promise> { + const [betaRes, releaseRes] = await Promise.all([ + fetch(API_BETA), + fetch(API_RELEASE), + ]); + + if (!betaRes.ok || !releaseRes.ok) { + throw new Error("Failed to fetch Zotero version information from API"); + } + + const [beta, release] = await Promise.all([ + betaRes.json(), + releaseRes.json(), + ]); + + return { + beta: beta as Record, + release: release as Record, + }; +} + +/** + * Load version cache from disk + */ +async function loadCache(): Promise { + try { + if (!await pathExists(CACHE_FILE)) { + return null; + } + + const cache = await readJSON(CACHE_FILE) as ZoteroVersionInfo; + const age = Date.now() - cache.lastUpdated; + + // Check if cache is still valid + if (age > CACHE_TTL) { + return null; + } + + return cache; + } + catch { + return null; + } +} + +/** + * Save version cache to disk + */ +async function saveCache(data: Omit): Promise { + await ensureDir(CACHE_DIR); + const cache: ZoteroVersionInfo = { + lastUpdated: Date.now(), + ...data, + }; + await writeJSON(CACHE_FILE, cache, { spaces: 2 }); +} + +/** + * Get Zotero version information with caching + */ +export async function getZoteroVersionInfo(): Promise { + // Try to load from cache first + const cachedData = await loadCache(); + if (cachedData) { + return cachedData; + } + + // Fetch from API if cache is not available + const freshData = await fetchVersionsFromAPI(); + await saveCache(freshData); + + return { + lastUpdated: Date.now(), + ...freshData, + }; +} + +/** + * Parse version string to extract major version + * E.g., "9.0-beta.21+1a89239a1" -> 9, "9.0" -> 9 + */ +export function parseMajorVersion(version: string): number { + const match = version.match(/^(\d+)/); + if (!match) { + throw new Error(`Invalid version format: ${version}`); + } + return Number.parseInt(match[1], 10); +} + +/** + * Compare two version strings + * Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 + */ +export function compareVersions(v1: string, v2: string): number { + const v1Parts = v1.split(/[.-]/).map((p) => { + const num = Number.parseInt(p, 10); + return Number.isNaN(num) ? 0 : num; + }); + const v2Parts = v2.split(/[.-]/).map((p) => { + const num = Number.parseInt(p, 10); + return Number.isNaN(num) ? 0 : num; + }); + + for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { + const p1 = v1Parts[i] ?? 0; + const p2 = v2Parts[i] ?? 0; + + if (p1 < p2) + return -1; + if (p1 > p2) + return 1; + } + + return 0; +}