diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 84d8741..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [ "main", "master" ] - pull_request: - branches: [ "main", "master" ] - schedule: - - cron: '30 1 * * 1' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript-typescript' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - # Queries can be 'security-extended' or 'security-and-quality' - queries: security-extended - - - name: Auto-build - uses: github/codeql-action/autobuild@v4 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index 5e9bb59..c9de6f6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ data.json # test coverage coverage/ +# generated by scripts/typecheck-compat.mjs +tsconfig.compat.generated.json + # Claude worktrees .claude/ diff --git a/manifest.json b/manifest.json index 2638073..1e2424b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "id": "git-file-sync", "name": "Git File Sync", - "version": "1.2.0", - "minAppVersion": "1.13.0", + "version": "1.2.1", + "minAppVersion": "1.11.0", "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.", "author": "ClaudiaFang", "authorUrl": "https://firstsun.heavenfortress.com/en/", diff --git a/package-lock.json b/package-lock.json index 90629d4..8b7e0cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git-file-sync", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-file-sync", - "version": "1.2.0", + "version": "1.2.1", "license": "MIT", "dependencies": { "ignore": "^7.0.5", @@ -1602,9 +1602,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1622,9 +1619,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1642,9 +1636,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1662,9 +1653,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1682,9 +1670,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1702,9 +1687,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7172,9 +7154,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7196,9 +7175,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7220,9 +7196,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7244,9 +7217,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index de29e90..1f10292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "git-file-sync", - "version": "1.2.0", + "version": "1.2.1", "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.", "main": "main.js", "type": "module", @@ -11,7 +11,8 @@ }, "scripts": { "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "build": "tsc -noEmit -skipLibCheck && npm run typecheck:compat && node esbuild.config.mjs production", + "typecheck:compat": "node scripts/typecheck-compat.mjs", "version": "node version-bump.mjs && git add manifest.json versions.json", "lint": "eslint .", "test": "vitest run", diff --git a/scripts/typecheck-compat.mjs b/scripts/typecheck-compat.mjs new file mode 100644 index 0000000..d6821db --- /dev/null +++ b/scripts/typecheck-compat.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/* + * Type-checks src/ against the exact Obsidian API version this release targets. + * Any use of an API newer than our declared minAppVersion that is NOT + * runtime-guarded becomes a compile error here. + * + * The target version is derived dynamically — never hardcoded. versions.json + * maps each plugin version to its minAppVersion; we use the entry for the + * current manifest version, falling back to manifest.minAppVersion (which is the + * canonical floor, and the reliable source when version-bump.mjs omits a + * versions.json entry because the minAppVersion was unchanged). + * + * The matching Obsidian typings are installed on demand into node_modules/.cache + * (git-ignored) and reused across runs, so bumping minAppVersion automatically + * re-points this check with no code changes. + */ +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; +import process from 'node:process'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const readJson = (p) => JSON.parse(readFileSync(join(root, p), 'utf8')); + +const manifest = readJson('manifest.json'); +const versions = readJson('versions.json'); +const target = versions[manifest.version] ?? manifest.minAppVersion; + +if (!target) { + console.error('compat: could not resolve target Obsidian version from versions.json / manifest.json'); + process.exit(1); +} + +// Install the target typings into a per-version cache (reused on later runs). +const cacheDir = join(root, 'node_modules', '.cache', 'obsidian-compat', target); +const dtsBase = join(cacheDir, 'node_modules', 'obsidian', 'obsidian'); // no extension +if (!existsSync(`${dtsBase}.d.ts`)) { + mkdirSync(cacheDir, { recursive: true }); + console.log(`compat: installing obsidian@${target} typings...`); + execSync( + `npm install --prefix "${cacheDir}" --no-save --no-audit --no-fund --loglevel=error obsidian@${target}`, + { stdio: 'inherit' }, + ); +} + +// Generate a tsconfig that points the "obsidian" import at the target typings. +const generated = join(root, 'tsconfig.compat.generated.json'); +writeFileSync(generated, `${JSON.stringify({ + extends: './tsconfig.json', + compilerOptions: { + noEmit: true, + skipLibCheck: true, + baseUrl: '.', + paths: { obsidian: [relative(root, dtsBase).split('\\').join('/')] }, + }, + include: ['src/**/*.ts'], +}, null, '\t')}\n`); + +console.log(`compat: type-checking src against Obsidian ${target} API...`); +execSync(`node "${join(root, 'node_modules', 'typescript', 'bin', 'tsc')}" -p "${generated}"`, { stdio: 'inherit' }); +console.log(`compat: ✓ src is compatible with Obsidian ${target}`); diff --git a/src/settings.ts b/src/settings.ts index 5947bd5..288f9c5 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,6 +1,15 @@ -import {App, PluginSettingTab, Setting, Notice, TextComponent, SettingDefinitionItem} from 'obsidian'; +import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian'; import GitLabFilesPush from "./main"; +// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so +// the plugin still type-checks against older Obsidian typings (minAppVersion +// 1.11.0), where this type does not exist. Obsidian only calls +// getSettingDefinitions() on versions that understand it. +interface SettingDefinitionItem { + name: string; + render: (setting: unknown, group: { listEl: HTMLElement }) => void; +} + export interface SyncMetadata { lastSyncedSha: string; lastSyncedAt: number; @@ -83,8 +92,9 @@ export class GitLabSyncSettingTab extends PluginSettingTab { this.plugin = plugin; } - // Kept as a fallback for Obsidian < 1.13.0 (this plugin's minAppVersion), - // which don't know about getSettingDefinitions() and always call display(). + // Kept as a fallback for Obsidian < 1.13.0 (older than 1.13, down to + // minAppVersion 1.11.0), which don't know about getSettingDefinitions() + // and always call display(). display(): void { this.renderSettings(this.containerEl); } @@ -99,8 +109,12 @@ export class GitLabSyncSettingTab extends PluginSettingTab { } private refresh(): void { - if (typeof this.update === 'function') { - this.update(); + // update() only exists on Obsidian >= 1.13. On older versions (down to + // minAppVersion 1.11.0) re-render manually instead. Accessed via a cast + // so this compiles against the 1.11 typings, which lack update(). + const maybeUpdate = (this as { update?: () => void }).update; + if (typeof maybeUpdate === 'function') { + maybeUpdate.call(this); } else { this.renderSettings(this.containerEl); } diff --git a/src/ui/SyncConflictModal.ts b/src/ui/SyncConflictModal.ts index 655de05..10c7e49 100644 --- a/src/ui/SyncConflictModal.ts +++ b/src/ui/SyncConflictModal.ts @@ -1,5 +1,20 @@ import { App, Modal, Setting } from 'obsidian'; +/** + * Apply the "destructive" button style, but only when the running Obsidian + * supports it. ButtonComponent.setDestructive() was added in Obsidian 1.13; on + * older versions (down to this plugin's minAppVersion, 1.11.0) the method is + * absent, so we skip it instead of throwing "setDestructive is not a function". + * Returns the same button so it can be chained. + */ +export function applyDestructiveStyle(btn: T): T { + const setDestructive = (btn as { setDestructive?: () => unknown }).setDestructive; + if (typeof setDestructive === 'function') { + setDestructive.call(btn); + } + return btn; +} + export class SyncConflictModal extends Modal { private readonly fileName: string; private readonly localContent: string; @@ -52,10 +67,9 @@ export class SyncConflictModal extends Modal { this.onChoose('local'); this.close(); })) - .addButton(btn => btn + .addButton(btn => applyDestructiveStyle(btn) .setButtonText('Keep remote') .setTooltip('Overwrite local with remote content') - .setDestructive() .onClick(() => { this.onChoose('remote'); this.close(); diff --git a/tests/ui/SyncConflictModal.test.ts b/tests/ui/SyncConflictModal.test.ts new file mode 100644 index 0000000..73ae781 --- /dev/null +++ b/tests/ui/SyncConflictModal.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest'; +import { applyDestructiveStyle } from '../../src/ui/SyncConflictModal'; + +// Guards the backward-compatibility fix that lets the plugin run on Obsidian +// down to minAppVersion 1.11.0. ButtonComponent.setDestructive() only exists on +// Obsidian >= 1.13; calling it unconditionally would throw on older versions. +describe('applyDestructiveStyle (Obsidian version compatibility)', () => { + it('applies the destructive style on Obsidian >= 1.13 (method present)', () => { + const setDestructive = vi.fn(); + const btn = { setDestructive }; + + expect(applyDestructiveStyle(btn)).toBe(btn); + expect(setDestructive).toHaveBeenCalledOnce(); + }); + + it('degrades gracefully on Obsidian < 1.13 (method absent) without throwing', () => { + const btn = {}; // an older ButtonComponent has no setDestructive() + + expect(() => applyDestructiveStyle(btn)).not.toThrow(); + expect(applyDestructiveStyle(btn)).toBe(btn); + }); + + it('ignores a non-function setDestructive rather than crashing', () => { + const btn = { setDestructive: 'nope' as unknown as () => unknown }; + + expect(() => applyDestructiveStyle(btn)).not.toThrow(); + }); +}); diff --git a/versions.json b/versions.json index 66971d8..545bdf3 100644 --- a/versions.json +++ b/versions.json @@ -5,5 +5,6 @@ "1.0.6": "1.12.7", "1.1.1": "1.12.7", "1.1.2": "1.12.7", - "1.2.0": "1.13.0" + "1.2.0": "1.13.0", + "1.2.1": "1.11.0" } \ No newline at end of file