diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..9f7ae4f --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,19 @@ +name: Build +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6.0.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/eslint.config.mts b/eslint.config.mts index 5c66cb8..3062c4a 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -13,25 +13,15 @@ export default tseslint.config( projectService: { allowDefaultProject: [ 'eslint.config.js', - 'manifest.json', - 'vitest.config.ts', + 'manifest.json' ] }, - ttsconfigRootDir: import.meta.dirname, + tsconfigRootDir: import.meta.dirname, extraFileExtensions: ['.json'] }, }, - rules: { - 'no-alert': 'off', // Allow confirm dialogs for user confirmation - '@typescript-eslint/await-thenable': 'off', // Settings methods may not return promises - } }, ...obsidianmd.configs.recommended, - { - rules: { - 'obsidianmd/ui/sentence-case': 'off', - } - }, globalIgnores([ "node_modules", "dist", diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..f51d524 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,14 @@ +sonar.projectKey=firstsun-dev_git-files-sync +sonar.organization=firstsun-dev + + +# This is the name and version displayed in the SonarCloud UI. +#sonar.projectName=git-files-sync +#sonar.projectVersion=1.0 + + +# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. +#sonar.sources=. + +# Encoding of the source code. Default is default system encoding +#sonar.sourceEncoding=UTF-8 diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index eb5df37..efdee0b 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -232,13 +232,11 @@ export class SyncManager { } private async saveSettings() { - /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ - // @ts-ignore - access private method to save settings - const plugin = (this.app as any).plugins?.plugins?.['git-file-sync']; - if (plugin) { + const plugins = (this.app as unknown as { plugins: { plugins: Record Promise }> } }).plugins; + const plugin = plugins?.plugins?.['git-file-sync']; + if (plugin && typeof plugin.saveSettings === 'function') { await plugin.saveSettings(); } - /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ } async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { diff --git a/src/main.ts b/src/main.ts index d296d3e..7cdf646 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { GitServiceInterface } from './services/git-service-interface'; import { SyncManager } from './logic/sync-manager'; import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; import { GitignoreManager } from './logic/gitignore-manager'; +import { ConfirmModal } from './ui/ConfirmModal'; export default class GitLabFilesPush extends Plugin { settings: GitLabFilesPushSettings; @@ -40,7 +41,7 @@ export default class GitLabFilesPush extends Plugin { const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${serviceName}`, (evt: MouseEvent) => { + this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${serviceName}`, () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { void this.sync.pushFile(activeView.file); @@ -240,9 +241,12 @@ export default class GitLabFilesPush extends Plugin { private showConfirmDialog(message: string): Promise { return new Promise((resolve) => { - // eslint-disable-next-line no-alert - const confirmed = confirm(message); - resolve(confirmed); + new ConfirmModal( + this.app, + message, + () => resolve(true), + () => resolve(false) + ).open(); }); } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 208cfb2..7842f01 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -174,7 +174,7 @@ export class GitHubService implements GitServiceInterface { } } - async listFiles(branch: string, path: string = ''): Promise { + async listFiles(branch: string, _path: string = ''): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; const response = await this.safeRequest({ diff --git a/src/settings.ts b/src/settings.ts index 1f35dca..2dea553 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -163,7 +163,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab { private displayGitHubSettings(containerEl: HTMLElement): void { new Setting(containerEl) .setName('GitHub personal access token') - .setDesc('Create a token in GitHub settings > Developer settings > Personal access tokens with "repo" scope') + .setDesc('Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope') .addText(text => text .setPlaceholder('Enter your token') .setValue(this.plugin.settings.githubToken) @@ -177,7 +177,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab { .setName('Repository owner') .setDesc('GitHub username or organization name') .addText(text => text - .setPlaceholder('username') + .setPlaceholder('Username') .setValue(this.plugin.settings.githubOwner) .onChange((value) => { this.plugin.settings.githubOwner = value; @@ -189,7 +189,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab { .setName('Repository name') .setDesc('Name of the GitHub repository') .addText(text => text - .setPlaceholder('my-notes') + .setPlaceholder('My notes') .setValue(this.plugin.settings.githubRepo) .onChange((value) => { this.plugin.settings.githubRepo = value; diff --git a/src/ui/ConfirmModal.ts b/src/ui/ConfirmModal.ts new file mode 100644 index 0000000..08eeaaf --- /dev/null +++ b/src/ui/ConfirmModal.ts @@ -0,0 +1,42 @@ +import { App, Modal, ButtonComponent } from 'obsidian'; + +export class ConfirmModal extends Modal { + private message: string; + private onConfirm: () => void; + private onCancel?: () => void; + + constructor(app: App, message: string, onConfirm: () => void, onCancel?: () => void) { + super(app); + this.message = message; + this.onConfirm = onConfirm; + this.onCancel = onCancel; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl('h3', { text: 'Confirm' }); + contentEl.createEl('p', { text: this.message }); + + const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' }); + + new ButtonComponent(buttonContainer) + .setButtonText('Cancel') + .onClick(() => { + this.close(); + if (this.onCancel) this.onCancel(); + }); + + new ButtonComponent(buttonContainer) + .setButtonText('Confirm') + .setCta() + .onClick(() => { + this.close(); + this.onConfirm(); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index c2c4e6a..31abe23 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,5 +1,6 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; +import { ConfirmModal } from './ConfirmModal'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; @@ -43,12 +44,13 @@ export class SyncStatusView extends ItemView { getDisplayText(): string { return 'Sync status'; } getIcon(): string { return 'git-compare'; } - async onOpen(): Promise { + onOpen(): Promise { const container = this.containerEl.children[1]; - if (!container) return; + if (!container) return Promise.resolve(); container.empty(); container.addClass('sync-status-view'); this.renderView(); + return Promise.resolve(); } private renderView(): void { @@ -189,7 +191,7 @@ export class SyncStatusView extends ItemView { const delBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-delete' }); delBtn.createSpan({ text: '✕' }); - delBtn.createSpan({ cls: 'ssv-btn-label', text: ` Del (${canDelete})` }); + delBtn.createSpan({ cls: 'ssv-btn-label', text: ` Delete (${canDelete})` }); delBtn.disabled = canDelete === 0; setTooltip(delBtn, `Delete ${canDelete} files`); delBtn.addEventListener('click', () => void this.deleteSelected()); @@ -249,7 +251,9 @@ export class SyncStatusView extends ItemView { diffEl.toggleClass('visible', !open); btnLabel.setText(open ? ' Diff' : ' Hide'); const firstChild = diffBtn.firstChild; - if (firstChild) firstChild.textContent = open ? '≡' : '▴'; + if (firstChild instanceof HTMLElement || firstChild instanceof Text) { + firstChild.textContent = open ? '≡' : '▴'; + } }); } @@ -820,12 +824,18 @@ export class SyncStatusView extends ItemView { this.renderView(); } - async onClose(): Promise { /* cleanup */ } + onClose(): Promise { + return Promise.resolve(); + } private showConfirmDialog(message: string): Promise { return new Promise(resolve => { - // eslint-disable-next-line no-alert - resolve(confirm(message)); + new ConfirmModal( + this.app, + message, + () => resolve(true), + () => resolve(false) + ).open(); }); } } diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 62b8301..bb81e5a 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-return */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SyncManager } from '../../src/logic/sync-manager'; @@ -99,7 +98,7 @@ describe('SyncManager', () => { // Capture the callback passed to the modal let callback: (choice: 'local' | 'remote') => void = () => {}; - modalMock.mockImplementation(function(app, file, local, remote, onChoose) { + modalMock.mockImplementation((app, file, local, remote, onChoose) => { callback = onChoose; return { open: vi.fn(), @@ -112,7 +111,7 @@ describe('SyncManager', () => { onOpen: vi.fn(), onClose: vi.fn(), setTitle: vi.fn().mockReturnThis(), - } as any; + } as unknown as SyncConflictModal; }); await manager.pushFile(mockFile); @@ -123,7 +122,7 @@ describe('SyncManager', () => { // Wait for async operations in callback await new Promise(resolve => setTimeout(resolve, 0)); - const pushSpy = mockGitLab.pushFile as any; + const pushSpy = vi.spyOn(mockGitLab, 'pushFile'); expect(pushSpy).toHaveBeenCalledWith('test.md', 'local content', 'main', 'Update test.md from Obsidian', 'remote-sha'); expect(mockSettings.syncMetadata['test.md']?.lastSyncedSha).toBe('new-sha'); }); @@ -139,7 +138,7 @@ describe('SyncManager', () => { const modalMock = vi.mocked(SyncConflictModal); let callback: (choice: 'local' | 'remote') => void = () => {}; - modalMock.mockImplementation(function(app, file, local, remote, onChoose) { + modalMock.mockImplementation((app, file, local, remote, onChoose) => { callback = onChoose; return { open: vi.fn(), @@ -152,7 +151,7 @@ describe('SyncManager', () => { onOpen: vi.fn(), onClose: vi.fn(), setTitle: vi.fn().mockReturnThis(), - } as any; + } as unknown as SyncConflictModal; }); await manager.pushFile(mockFile); @@ -206,8 +205,10 @@ describe('SyncManager', () => { await manager.pushFile(mockFile); - expect(mockGitLab.getFile).not.toHaveBeenCalled(); - expect(mockGitLab.pushFile).not.toHaveBeenCalled(); + const getFileSpy = vi.spyOn(mockGitLab, 'getFile'); + const pushFileSpy = vi.spyOn(mockGitLab, 'pushFile'); + expect(getFileSpy).not.toHaveBeenCalled(); + expect(pushFileSpy).not.toHaveBeenCalled(); }); it('should add new file to repo when it exists locally but not on remote', async () => { @@ -222,7 +223,8 @@ describe('SyncManager', () => { await manager.pushFile(mockFile); - expect(mockGitLab.pushFile).toHaveBeenCalledWith( + const pushFileSpy = vi.spyOn(mockGitLab, 'pushFile'); + expect(pushFileSpy).toHaveBeenCalledWith( 'new.md', 'new local content', 'main', diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 0ebd035..fbc4503 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitLabService } from '../../src/services/gitlab-service'; import { requestUrl, RequestUrlResponse } from 'obsidian'; @@ -49,14 +48,14 @@ describe('GitLabService', () => { }); it('should return blob_id as sha', async () => { - const mockResponse: any = { + const mockResponse: unknown = { status: 200, json: { content: btoa('test content'), blob_id: 'test-blob-id' } }; - vi.mocked(requestUrl).mockResolvedValue(mockResponse as unknown as RequestUrlResponse); + vi.mocked(requestUrl).mockResolvedValue(mockResponse as RequestUrlResponse); const result = await service.getFile('test.md', 'main'); expect(result.sha).toBe('test-blob-id'); }); @@ -74,7 +73,7 @@ describe('GitLabService', () => { expect(result).toBe('test.md'); expect(requestUrl).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'POST', - body: expect.stringContaining('bmV3IGNvbnRlbnQ=') + body: expect.stringContaining(btoa('new content')) as unknown as string })); }); @@ -92,7 +91,7 @@ describe('GitLabService', () => { expect(result).toBe('test.md'); expect(requestUrl).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'PUT', - body: expect.stringContaining('dXBkYXRlZCBjb250ZW50') + body: expect.stringContaining(btoa('updated content')) as unknown as string })); }); }); diff --git a/tests/setup.ts b/tests/setup.ts index e77a1c5..d7d6737 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -17,21 +17,21 @@ if (typeof window === 'undefined') { // Mock Obsidian API components export const Plugin = class {}; export const PluginSettingTab = class { - constructor(_app: unknown, _plugin: unknown) {} + constructor() {} }; export const Setting = class { - constructor(_containerEl: unknown) {} - setName(_name: string) { return this; } - setDesc(_desc: string) { return this; } - addText(_cb: unknown) { return this; } - addToggle(_cb: unknown) { return this; } - addButton(_cb: unknown) { return this; } + constructor() {} + setName() { return this; } + setDesc() { return this; } + addText() { return this; } + addToggle() { return this; } + addButton() { return this; } }; export const Notice = class { - constructor(message: string) {} + constructor() {} }; export const Modal = class { - constructor(_app: unknown) {} + constructor() {} open() {} close() {} }; diff --git a/tsconfig.json b/tsconfig.json index 3cd62ad..9c2f8a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ }, "include": [ "src/**/*.ts", - "tests/**/*.ts" + "tests/**/*.ts", + "vitest.config.ts" ] } diff --git a/version-bump.mjs b/version-bump.mjs index 55d631f..c71cf4d 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -1,4 +1,5 @@ import { readFileSync, writeFileSync } from "fs"; +import process from "process"; const targetVersion = process.env.npm_package_version; diff --git a/vitest.config.ts b/vitest.config.ts index 522204f..ba68b1b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,4 @@ import { defineConfig } from 'vitest/config'; -// eslint-disable-next-line import/no-nodejs-modules -import * as path from 'path'; export default defineConfig({ test: { @@ -8,8 +6,7 @@ export default defineConfig({ globals: true, setupFiles: ['./tests/setup.ts'], alias: { - // eslint-disable-next-line no-undef - 'obsidian': path.resolve(process.cwd(), './tests/setup.ts') + 'obsidian': './tests/setup.ts' } }, });