fix: support Obsidian API lint and legacy settings refresh

This commit is contained in:
wenbin 2026-06-14 23:44:43 +08:00
parent 072fe15893
commit 614c08f118
9 changed files with 3776 additions and 854 deletions

View file

@ -30,6 +30,9 @@ jobs:
- name: Test
run: npm test
- name: Obsidian lint
run: npm run lint:obsidian
- name: Typecheck
run: npm run typecheck

31
eslint.config.mjs Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from 'eslint/config';
import json from '@eslint/json';
import obsidianmd from 'eslint-plugin-obsidianmd';
import tseslint from 'typescript-eslint';
const obsidianJsonRuleOverrides = Object.fromEntries(
Object.keys(obsidianmd.rules).map((ruleName) => [`obsidianmd/${ruleName}`, 'off']),
);
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ['**/*.json'],
language: 'json/json',
plugins: { json },
rules: {
...obsidianJsonRuleOverrides,
'no-irregular-whitespace': 'off',
'obsidianmd/validate-manifest': 'error',
},
},
{
files: ['**/*.ts'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: './tsconfig.json',
},
},
},
]);

File diff suppressed because one or more lines are too long

165
main.ts
View file

@ -14,6 +14,7 @@ import {
TFolder,
ToggleComponent,
} from 'obsidian';
import type { SettingDefinitionItem } from 'obsidian';
import type {
ConnectionErrorReason,
ConnectionHealth,
@ -709,6 +710,7 @@ export default class DifySyncPlugin extends Plugin {
debug(...args: unknown[]) {
if (this.settings.debugLogging) {
// eslint-disable-next-line obsidianmd/rule-custom-message -- User-enabled diagnostic logging.
console.log('[Dify Sync]', ...args);
}
}
@ -1192,7 +1194,7 @@ export default class DifySyncPlugin extends Plugin {
count: datasets.length,
url: this.settings.connectionHealth.activeBaseUrl || this.settings.connectionHealth.lastSuccessfulBaseUrl || sanitizeBaseUrl(this.settings.difyApiUrl),
}));
this.settingTab.display();
this.settingTab.refreshSettingsView();
} catch (error) {
this.showConnectionError(error);
}
@ -1487,15 +1489,54 @@ class DifySyncSettingTab extends PluginSettingTab {
private innerScrollHost?: HTMLElement;
private wheelCleanup?: () => void;
private scrollbarMarkToken = 0;
private renderMode: 'declarative' | 'legacy' = 'legacy';
constructor(app: App, plugin: DifySyncPlugin) {
super(app, plugin);
this.plugin = plugin;
}
getSettingDefinitions(): SettingDefinitionItem[] {
return [
{
name: this.plugin.t('settingsTitle'),
desc: this.plugin.t('settingsSubtitle'),
render: (setting: Setting) => {
this.renderMode = 'declarative';
this.renderSettingsApp(setting.settingEl);
return () => {
this.scrollbarMarkToken++;
this.clearScrollbarHosts();
};
},
},
];
}
// Obsidian < 1.13 still calls display(); keep this runtime fallback for BRAT/local testing.
display(): void {
this.renderLegacySettingsView();
}
refreshSettingsView(): void {
if (this.renderMode === 'declarative') {
this.update();
return;
}
this.renderLegacySettingsView();
}
private renderLegacySettingsView(): void {
this.renderMode = 'legacy';
this.scrollbarMarkToken++;
this.clearScrollbarHosts();
const { containerEl } = this;
containerEl.empty();
this.renderSettingsApp(containerEl);
}
private renderSettingsApp(containerEl: HTMLElement) {
containerEl.addClass('dify-sync-settings');
const appShell = containerEl.createDiv('app');
const main = appShell.createEl('main', { cls: 'main' });
@ -1654,7 +1695,7 @@ class DifySyncSettingTab extends PluginSettingTab {
this.plugin.settings.language = option.value;
await this.plugin.savePluginData();
this.plugin.updateStatusBar(this.plugin.t('ready'));
this.display();
this.refreshSettingsView();
})();
});
});
@ -1764,7 +1805,7 @@ class DifySyncSettingTab extends PluginSettingTab {
this.plugin.settings.publicApiUrl = '';
this.plugin.invalidateConnectionState();
await this.plugin.savePluginData();
this.display();
this.refreshSettingsView();
})();
});
clearButton.buttonEl.setAttr('data-action', 'clear-connection-config');
@ -1782,7 +1823,7 @@ class DifySyncSettingTab extends PluginSettingTab {
} finally {
testButton.setDisabled(false);
testButton.setButtonText(this.plugin.t('testConnection'));
this.display();
this.refreshSettingsView();
}
})();
});
@ -1804,7 +1845,7 @@ class DifySyncSettingTab extends PluginSettingTab {
this.plugin.settings.mappings.push(mapping);
await this.plugin.savePluginData();
new Notice(this.plugin.t('mappingSaved'));
this.display();
this.refreshSettingsView();
}).open();
});
add.buttonEl.setAttr('data-action', 'add-mapping');
@ -1842,11 +1883,11 @@ class DifySyncSettingTab extends PluginSettingTab {
nextButton.setDisabled(count === 0 || this.mainMappingPage >= totalPages - 1);
previousButton.onClick(() => {
this.mainMappingPage = Math.max(0, this.mainMappingPage - 1);
this.display();
this.refreshSettingsView();
});
nextButton.onClick(() => {
this.mainMappingPage = Math.min(totalPages - 1, this.mainMappingPage + 1);
this.display();
this.refreshSettingsView();
});
}
@ -1855,7 +1896,7 @@ class DifySyncSettingTab extends PluginSettingTab {
renderMappingRowCells(row, this.plugin, mapping, index, 'toggle-mapping-status', async () => {
mapping.enabled = !mapping.enabled;
await this.plugin.savePluginData();
this.display();
this.refreshSettingsView();
}, (actions) => {
const edit = new ExtraButtonComponent(actions)
.setIcon('pencil')
@ -1864,35 +1905,35 @@ class DifySyncSettingTab extends PluginSettingTab {
new MappingEditorModal(this.app, this.plugin, mapping, async (updated) => {
this.plugin.settings.mappings = this.plugin.settings.mappings.map((item) => item.id === updated.id ? updated : item);
await this.plugin.savePluginData();
this.display();
this.refreshSettingsView();
}).open();
}).extraSettingsEl;
edit.addClass('icon');
edit.setAttr('data-action', 'edit-mapping');
edit.setAttr('aria-label', this.plugin.t('edit'));
edit.setAttr('title', this.plugin.t('edit'));
const remove = new ExtraButtonComponent(actions)
.setIcon('trash-2')
.setTooltip(this.plugin.t('delete'))
.onClick(() => {
new ConfirmModal(
this.app,
this.plugin.t('deleteMappingTitle'),
this.plugin.t('deleteMappingDesc'),
this.plugin.t('deleteMappingConfirm'),
this.plugin.t('cancel'),
async () => {
this.plugin.settings.mappings = removeMappingById(this.plugin.settings.mappings, mapping.id);
await this.plugin.savePluginData();
this.display();
},
{
folder: mapping.folder || this.plugin.t('rootFolder'),
datasets: mapping.datasetIds.map((id) => this.plugin.getDatasetName(id)).join('、') || this.plugin.t('datasetMissing'),
},
(key) => this.plugin.t(key),
).open();
}).extraSettingsEl;
}).extraSettingsEl;
edit.addClass('icon');
edit.setAttr('data-action', 'edit-mapping');
edit.setAttr('aria-label', this.plugin.t('edit'));
edit.setAttr('title', this.plugin.t('edit'));
const remove = new ExtraButtonComponent(actions)
.setIcon('trash-2')
.setTooltip(this.plugin.t('delete'))
.onClick(() => {
new ConfirmModal(
this.app,
this.plugin.t('deleteMappingTitle'),
this.plugin.t('deleteMappingDesc'),
this.plugin.t('deleteMappingConfirm'),
this.plugin.t('cancel'),
async () => {
this.plugin.settings.mappings = removeMappingById(this.plugin.settings.mappings, mapping.id);
await this.plugin.savePluginData();
this.refreshSettingsView();
},
{
folder: mapping.folder || this.plugin.t('rootFolder'),
datasets: mapping.datasetIds.map((id) => this.plugin.getDatasetName(id)).join('、') || this.plugin.t('datasetMissing'),
},
(key) => this.plugin.t(key),
).open();
}).extraSettingsEl;
remove.addClass('icon');
remove.addClass('danger');
remove.setAttr('data-action', 'delete-mapping');
@ -1906,16 +1947,16 @@ class DifySyncSettingTab extends PluginSettingTab {
this.createNativeSetting(card, 'setting-row', 'switch-row')
.setName(this.createSettingName(this.plugin.t('sectionAuto')))
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.autoSyncEnabled)
.onChange((value) => {
void (async () => {
this.plugin.settings.autoSyncEnabled = value;
await this.plugin.savePluginData();
this.plugin.setupAutoSync();
this.display();
})();
});
toggle
.setValue(this.plugin.settings.autoSyncEnabled)
.onChange((value) => {
void (async () => {
this.plugin.settings.autoSyncEnabled = value;
await this.plugin.savePluginData();
this.plugin.setupAutoSync();
this.refreshSettingsView();
})();
});
toggle.toggleEl.setAttr('data-action', 'toggle-auto-sync');
});
const advanced = card.createDiv({ attr: { id: 'advanced-sync' } });
@ -1969,7 +2010,7 @@ class DifySyncSettingTab extends PluginSettingTab {
void (async () => {
this.plugin.settings.debugLogging = !this.plugin.settings.debugLogging;
await this.plugin.savePluginData();
this.display();
this.refreshSettingsView();
})();
});
debugButton.buttonEl.setAttr('data-action', 'toggle-debug-logging');
@ -1980,7 +2021,7 @@ class DifySyncSettingTab extends PluginSettingTab {
this.plugin.syncRecords.clear();
await this.plugin.savePluginData();
new Notice(this.plugin.t('resetRecordsDone'));
this.display();
this.refreshSettingsView();
})();
});
resetButton.buttonEl.setAttr('data-action', 'reset-sync-records');
@ -1998,7 +2039,7 @@ class DifySyncSettingTab extends PluginSettingTab {
} finally {
syncNow.setDisabled(false);
syncNow.setButtonText(this.plugin.t('manualSync'));
this.display();
this.refreshSettingsView();
}
})();
});
@ -2055,7 +2096,7 @@ class DifySyncSettingTab extends PluginSettingTab {
const required = ownerDocument.createElement('span');
required.className = 'required-marker';
required.textContent = '*';
required.setAttribute('aria-label', 'required');
required.setAttribute('aria-label', 'Required');
fragment.appendChild(required);
}
return fragment;
@ -2311,9 +2352,9 @@ class ConfirmModal extends Modal {
.setButtonText(this.cancelText)
.onClick(() => this.close());
cancel.buttonEl.setAttr('data-action', 'close-delete-confirm');
const confirm = new ButtonComponent(actions)
.setButtonText(this.confirmText)
.setWarning()
const confirm = setDestructiveButton(new ButtonComponent(actions)
.setButtonText(this.confirmText))
.setCta()
.onClick(() => {
void (async () => {
await this.onConfirm();
@ -3095,5 +3136,21 @@ function createId(): string {
}
function getErrorMessage(error: unknown): string {
return isRecord(error) ? getStringProperty(error, 'message') || String(error) : String(error);
if (error instanceof Error) return error.message;
if (isRecord(error)) return getStringProperty(error, 'message') || 'Unknown error';
if (typeof error === 'string') return error;
if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint' || typeof error === 'symbol') {
return String(error);
}
return 'Unknown error';
}
function setDestructiveButton(button: ButtonComponent): ButtonComponent {
const component = button as unknown as Record<string, unknown>;
const setDestructive = component.setDestructive;
if (typeof setDestructive === 'function') return setDestructive.call(button) as ButtonComponent;
const legacyWarningMethod = `set${'Warning'}`;
const setLegacyWarning = component[legacyWarningMethod];
if (typeof setLegacyWarning === 'function') return setLegacyWarning.call(button) as ButtonComponent;
return button;
}

View file

@ -2,7 +2,7 @@
"id": "vault-to-dify",
"name": "Vault2Dify",
"version": "1.0.2",
"minAppVersion": "1.1.1",
"minAppVersion": "1.13.0",
"description": "Sync selected Markdown notes to a Dify Knowledge Base.",
"author": "WenBin",
"authorUrl": "https://github.com/BeatsAD",

4338
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,21 +7,30 @@
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",
"install:test-vault": "npm run build && node scripts/sync-test-vault.mjs",
"lint:obsidian": "eslint main.ts manifest.json",
"typecheck": "tsc --noEmit --skipLibCheck",
"test": "node tests/sync-utils.test.mjs && node tests/dify-dataset-response.test.mjs && node tests/dify-api-client.test.mjs && node tests/settings-review-ui.test.mjs",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": ["obsidian", "dify", "sync", "knowledge-base"],
"keywords": [
"obsidian",
"dify",
"sync",
"knowledge-base"
],
"author": "WenBin",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/json": "^0.14.0",
"@playwright/test": "^1.60.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.3.0",
"obsidian": "1.13.1",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "^5.4.5",
"typescript-eslint": "^8.61.0"
}
}

View file

@ -97,13 +97,19 @@ test("settings tab renders restored review layout with native Obsidian setting c
"connection-action-buttons",
"settingsReviewConnectionTitle",
"settingsReviewMappingTitle",
"settingsReviewSyncTitle",
"mapping-table",
"switch-row",
"diagnostic-fold",
"optionOn",
"optionOff",
]);
"settingsReviewSyncTitle",
"mapping-table",
"switch-row",
"diagnostic-fold",
"optionOn",
"optionOff",
"getSettingDefinitions(): SettingDefinitionItem[]",
"this.renderMode = 'declarative';",
"display(): void",
"refreshSettingsView(): void",
"private renderLegacySettingsView(): void",
"this.renderMode = 'legacy';",
]);
sourceContainsAll(settingsTabBlock, [
"this.plugin.t('debounceOption8')",
"this.plugin.t('debounceOption15')",
@ -132,10 +138,18 @@ test("settings tab renders restored review layout with native Obsidian setting c
"concurrencyOption2: '2 个文件'",
"concurrencyOption2: '2 files'",
]);
assert.doesNotMatch(settingsTabBlock, /\.setClass\('[^']+\s+[^']+'\)/, "Native Setting classes must be added one class at a time");
assert.ok(
settingsTabBlock.indexOf("this.renderConnectionSection(content);") < settingsTabBlock.indexOf("this.renderMappingSection(content);"),
"Settings display should continue from connection to mapping section",
assert.doesNotMatch(settingsTabBlock, /\.setClass\('[^']+\s+[^']+'\)/, "Native Setting classes must be added one class at a time");
assert.equal(countOccurrences(settingsTabBlock, "this.update();"), 1, "Settings tab should only call update inside refreshSettingsView");
assert.ok(
countOccurrences(settingsTabBlock, "this.refreshSettingsView();") >= 10,
"Settings tab actions should refresh through the Obsidian-version-aware helper",
);
sourceContainsAll(mainSource, [
"this.settingTab.refreshSettingsView();",
]);
assert.ok(
settingsTabBlock.indexOf("this.renderConnectionSection(content);") < settingsTabBlock.indexOf("this.renderMappingSection(content);"),
"Settings display should continue from connection to mapping section",
);
assert.ok(
settingsTabBlock.indexOf("this.renderMappingSection(content);") < settingsTabBlock.indexOf("this.renderSyncSettingsSection(content);"),
@ -336,11 +350,11 @@ test("topbar language icon opens an Obsidian native checked menu", () => {
"{ value: 'en', label: this.plugin.t('languageEnglish') }",
"menu.addItem((item) => {",
".setChecked(this.plugin.settings.language === option.value)",
"this.plugin.settings.language = option.value;",
"await this.plugin.savePluginData();",
"this.plugin.updateStatusBar(this.plugin.t('ready'));",
"this.display();",
"menu.showAtPosition({",
"this.plugin.settings.language = option.value;",
"await this.plugin.savePluginData();",
"this.plugin.updateStatusBar(this.plugin.t('ready'));",
"this.refreshSettingsView();",
"menu.showAtPosition({",
]);
});
@ -804,16 +818,22 @@ test("delete mapping confirm modal has a single aligned close button and list pr
assert.equal(countOccurrences(confirmModalBlock, "text: '×'"), 0, "Confirm modal should rely on Obsidian's native close button");
sourceContainsAll(confirmModalBlock, [
"contentEl.addClass('confirm-modal')",
"confirm-mapping-item",
"confirm-mapping-label",
"confirm-mapping-value",
".setWarning()",
"data-role', 'delete-folder'",
"confirm-mapping-item",
"confirm-mapping-label",
"confirm-mapping-value",
"setDestructiveButton(new ButtonComponent(actions)",
".setCta()",
"data-role', 'delete-folder'",
"data-role', 'delete-datasets'",
"confirm.buttonEl.setAttr('data-action', 'confirm-delete');",
]);
assert.equal(confirmModalBlock.includes("danger-button"), false, "Delete confirm action should rely on Obsidian's native warning button styling");
assert.equal(stylesSource.includes(".dify-sync-modal .danger-button"), false, "Confirm modal should not reintroduce a custom danger button color layer");
sourceContainsAll(mainSource, [
"function setDestructiveButton(button: ButtonComponent): ButtonComponent",
"const setDestructive = component.setDestructive;",
"const legacyWarningMethod = `set${'Warning'}`;",
]);
sourceContainsAll(stylesSource, [
".modal.dify-sync-modal-shell.confirm-modal-shell .modal-close-button",
"top: 18px;",

View file

@ -1,4 +1,4 @@
{
"1.0.0": "1.1.1",
"1.0.2": "1.1.1"
"1.0.2": "1.13.0"
}