一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
428 行
15 KiB
TypeScript
428 行
15 KiB
TypeScript
import Setting, { AppType, SettingItem, SettingMetadataSection, SettingSectionSource, SettingValueType, SyncStartupOperation, type SettingsRecord } from '../../../models/Setting';
|
|
import SyncTargetRegistry from '../../../SyncTargetRegistry';
|
|
import { _ } from '../../../locale';
|
|
import { createSelector } from 'reselect';
|
|
import Logger from '@joplin/utils/Logger';
|
|
|
|
import { type ReactNode } from 'react';
|
|
import { type Registry } from '../../../registry';
|
|
import settingValidations from '../../../models/settings/settingValidations';
|
|
import { convertValuesToFunctions } from '../../../ObjectUtils';
|
|
import aiSettingsTransition from '../../../services/ai/aiSettingsTransition';
|
|
import { ChatRole } from '../../../services/ai/types';
|
|
import { openLoginScreen as openJoplinOAuthLogin } from '../../../services/joplinCloudUtils';
|
|
import shim from '../../../shim';
|
|
import CommandService from '../../../services/CommandService';
|
|
|
|
const logger = Logger.create('config-shared');
|
|
|
|
export type SettingsMap = Partial<SettingsRecord>;
|
|
|
|
export interface ConfigScreenState {
|
|
checkSyncConfigResult: { ok: boolean; errorMessage: string }|'checking'|null;
|
|
checkAiConfigResult: { ok: boolean; message: string }|'checking'|null;
|
|
settings: SettingsMap;
|
|
changedSettingKeys: string[];
|
|
showAdvancedSettings: boolean;
|
|
searchQuery: string;
|
|
searchSectionFilter: string|null;
|
|
}
|
|
|
|
export const defaultScreenState: ConfigScreenState = {
|
|
checkSyncConfigResult: null,
|
|
checkAiConfigResult: null,
|
|
settings: {},
|
|
changedSettingKeys: [],
|
|
showAdvancedSettings: false,
|
|
searchQuery: '',
|
|
searchSectionFilter: null,
|
|
};
|
|
|
|
interface ConfigScreenComponent {
|
|
settingToComponent(settingId: string, setting: unknown): ReactNode;
|
|
sectionToComponent(sectionName: string, section: SettingMetadataSection, settings: SettingsMap, isSelected: boolean): ReactNode;
|
|
|
|
state: Partial<ConfigScreenState>;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Mirrors React.Component.setState signature (Pick<S, K> etc.); a narrower local type breaks subclass assignment of `this` to ConfigScreenComponent
|
|
setState(callbackOrNew: any, callback?: ()=> void): void;
|
|
setSettingValue<Key extends string>(key: Key, value: SettingValueType<Key>): void;
|
|
}
|
|
|
|
interface SettingsSavedEvent {
|
|
savedSettingKeys: string[];
|
|
}
|
|
|
|
type OnSettingsSavedCallback = (event: SettingsSavedEvent)=> void;
|
|
|
|
let onSettingsSaved: OnSettingsSavedCallback = () => {};
|
|
|
|
export const init = (reg: Registry) => {
|
|
onSettingsSaved = (event) => {
|
|
const savedSettingKeys = event.savedSettingKeys;
|
|
|
|
// After changing the sync settings we immediately trigger a sync
|
|
// operation. This will ensure that the client gets the sync info as
|
|
// early as possible, in particular the encryption state (encryption
|
|
// keys, whether it's enabled, etc.). This should prevent situations
|
|
// where the user tried to setup E2EE on the client even though it's
|
|
// already been done on another client.
|
|
if (savedSettingKeys.find(s => s.startsWith('sync.'))) {
|
|
logger.info('Sync settings have been changed - scheduling a sync');
|
|
void reg.scheduleSync();
|
|
}
|
|
};
|
|
};
|
|
|
|
export const advancedSettingsButton_click = (comp: ConfigScreenComponent) => {
|
|
comp.setState((state: ConfigScreenState) => {
|
|
return { showAdvancedSettings: !state.showAdvancedSettings };
|
|
});
|
|
};
|
|
|
|
export const checkSyncConfig = async (comp: ConfigScreenComponent, settings: SettingsMap) => {
|
|
|
|
const syncTargetId = settings['sync.target'];
|
|
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId);
|
|
|
|
const options = {
|
|
...Setting.subValues(`sync.${syncTargetId}`, settings, { includeConstants: true }),
|
|
...Setting.subValues('net', settings, { includeConstants: true }) };
|
|
|
|
comp.setState({ checkSyncConfigResult: 'checking' });
|
|
const result = await SyncTargetClass.checkConfig(convertValuesToFunctions(options));
|
|
comp.setState({ checkSyncConfigResult: result });
|
|
|
|
if (result.ok) {
|
|
// Users often expect config to be auto-saved at this point, if the config check was successful
|
|
await saveSettings(comp);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
export const checkSyncConfigMessages = (comp: ConfigScreenComponent) => {
|
|
const result = comp.state.checkSyncConfigResult;
|
|
const output = [];
|
|
|
|
if (result === 'checking') {
|
|
output.push(_('Checking... Please wait.'));
|
|
} else if (result && result.ok) {
|
|
output.push(_('Success! Synchronisation configuration appears to be correct.'));
|
|
} else if (result && !result.ok) {
|
|
output.push(_('Error. Please check that URL, username, password, etc. are correct and that the sync target is accessible. The reported error was:'));
|
|
output.push(result.errorMessage);
|
|
}
|
|
|
|
return output;
|
|
};
|
|
|
|
export const checkAiConfig = async (comp: ConfigScreenComponent) => {
|
|
// Pending edits aren't persisted until Apply, so flush first — otherwise
|
|
// the test runs against the previously-saved provider config. saveSettings
|
|
// returns false when validation fails; bail out in that case so the user
|
|
// sees the validation message instead of a misleading AI error.
|
|
const saved = await saveSettings(comp);
|
|
if (!saved) return;
|
|
|
|
comp.setState({ checkAiConfigResult: 'checking' });
|
|
try {
|
|
const { default: AiService } = await import('../../../services/ai/AiService');
|
|
// Reasoning models spend hundreds of tokens before emitting any content,
|
|
// so a small budget returns empty text with finish_reason "length".
|
|
const result = await AiService.instance().chat([
|
|
{ role: ChatRole.System, content: 'Keep replies brief, but non-empty.' },
|
|
{ role: ChatRole.User, content: 'Reply with the single word OK.' },
|
|
], { maxTokens: 512 });
|
|
const text = (result.text || '').trim();
|
|
if (!text) {
|
|
// Truncation or a reasoning trace means the model was reached, so the
|
|
// base URL, key and model name are all fine.
|
|
if (result.finishReason === 'length' || result.reasoningText) {
|
|
comp.setState({ checkAiConfigResult: { ok: true, message: _('Connected successfully, but the model used its entire output budget on reasoning and returned no text.') } });
|
|
} else {
|
|
comp.setState({ checkAiConfigResult: { ok: false, message: _('The provider returned an empty response. Check that the base URL ends with /v1 and that a model is loaded.') } });
|
|
}
|
|
} else {
|
|
comp.setState({ checkAiConfigResult: { ok: true, message: text } });
|
|
}
|
|
} catch (error) {
|
|
// Log the full error to the console — the user-facing status only shows
|
|
// the short message, but the dev tools console gets the stack trace too.
|
|
logger.warn('AI configuration test failed:', error);
|
|
comp.setState({ checkAiConfigResult: { ok: false, message: error.message } });
|
|
}
|
|
};
|
|
|
|
export const checkAiConfigMessages = (comp: ConfigScreenComponent) => {
|
|
const result = comp.state.checkAiConfigResult;
|
|
const output: string[] = [];
|
|
|
|
if (result === 'checking') {
|
|
output.push(_('Checking... Please wait.'));
|
|
} else if (result && result.ok) {
|
|
output.push(_('Success! The AI provider responded:'));
|
|
output.push(result.message);
|
|
} else if (result && !result.ok) {
|
|
output.push(_('Error. The AI provider test failed:'));
|
|
output.push(result.message);
|
|
}
|
|
|
|
return output;
|
|
};
|
|
|
|
export const updateSettingValue = (comp: ConfigScreenComponent, key: string, value: unknown, callback?: ()=> void) => {
|
|
if (!callback) callback = () => {};
|
|
|
|
comp.setState((state: ConfigScreenState) => {
|
|
// @react-native-community/slider (4.4.0) will emit a valueChanged event
|
|
// when the component is mounted, even though the value hasn't changed.
|
|
// We should ignore this, otherwise it will mark the settings as
|
|
// unsaved.
|
|
//
|
|
// Upstream: https://github.com/callstack/react-native-slider/issues/395
|
|
//
|
|
// https://github.com/laurent22/joplin/issues/7503
|
|
if (state.settings[key] === value) {
|
|
logger.info('Trying to update a setting that has not changed - skipping it.', key, value);
|
|
return {};
|
|
}
|
|
|
|
const settings = { ...state.settings };
|
|
const changedSettingKeys = state.changedSettingKeys.slice();
|
|
settings[key] = Setting.formatValue(key, value);
|
|
if (changedSettingKeys.indexOf(key) < 0) changedSettingKeys.push(key);
|
|
|
|
// Live AI first-enable default: when the user ticks `ai.enabled` and
|
|
// they're on Joplin Cloud sync, pre-select Joplin Cloud AI in the
|
|
// form so the change is visible immediately (the same logic runs at
|
|
// save-time too, but applying it here makes the UI reflect it before
|
|
// the user clicks Apply). Read sync.target and configured from the
|
|
// pending form state with a fallback to the persisted value, so the
|
|
// hook reacts to changes the user has made in the same session but
|
|
// not yet applied.
|
|
const pendingSyncTarget = 'sync.target' in settings ? settings['sync.target'] : Setting.value('sync.target');
|
|
const pendingConfigured = 'ai.chat.providerType.configured' in settings ? settings['ai.chat.providerType.configured'] : Setting.value('ai.chat.providerType.configured');
|
|
if (
|
|
key === 'ai.enabled'
|
|
&& settings['ai.enabled'] === true
|
|
&& !pendingConfigured
|
|
&& pendingSyncTarget === SyncTargetRegistry.nameToId('joplinCloud')
|
|
) {
|
|
settings['ai.chat.providerType'] = 'joplin-cloud';
|
|
if (changedSettingKeys.indexOf('ai.chat.providerType') < 0) changedSettingKeys.push('ai.chat.providerType');
|
|
}
|
|
|
|
return {
|
|
settings: settings,
|
|
changedSettingKeys: changedSettingKeys,
|
|
};
|
|
}, callback);
|
|
|
|
const metadata = Setting.settingMetadata(key);
|
|
if (metadata.autoSave) {
|
|
scheduleSaveSettings(comp);
|
|
}
|
|
};
|
|
|
|
let scheduleSaveSettingsIID: ReturnType<typeof setTimeout>|null = null;
|
|
export const scheduleSaveSettings = (comp: ConfigScreenComponent) => {
|
|
if (scheduleSaveSettingsIID) clearTimeout(scheduleSaveSettingsIID);
|
|
|
|
scheduleSaveSettingsIID = setTimeout(async () => {
|
|
scheduleSaveSettingsIID = null;
|
|
await saveSettings(comp);
|
|
}, 100);
|
|
};
|
|
|
|
export const saveSettings = async (comp: ConfigScreenComponent) => {
|
|
const savedSettingKeys = comp.state.changedSettingKeys.slice();
|
|
|
|
const validationMessage = await settingValidations(savedSettingKeys, comp.state.settings);
|
|
if (validationMessage) {
|
|
alert(validationMessage);
|
|
return false;
|
|
}
|
|
|
|
aiSettingsTransition({ changedKeys: savedSettingKeys, settings: comp.state.settings });
|
|
|
|
for (const key in comp.state.settings) {
|
|
if (!comp.state.settings.hasOwnProperty(key)) continue;
|
|
if (savedSettingKeys.indexOf(key) < 0) continue;
|
|
Setting.setValue(key, comp.state.settings[key]);
|
|
}
|
|
|
|
comp.setState({ changedSettingKeys: [] });
|
|
|
|
onSettingsSaved({ savedSettingKeys });
|
|
|
|
return true;
|
|
};
|
|
|
|
export const settingsToComponents = (comp: ConfigScreenComponent, device: AppType, settings: SettingsMap) => {
|
|
const keys = Setting.keys(true, device);
|
|
const settingComps = [];
|
|
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
if (!Setting.isPublic(key)) continue;
|
|
|
|
const md = Setting.settingMetadata(key);
|
|
if (md.show && !md.show(settings)) continue;
|
|
|
|
const settingComp = comp.settingToComponent(key, settings[key]);
|
|
if (!settingComp) continue;
|
|
settingComps.push(settingComp);
|
|
}
|
|
|
|
return settingComps;
|
|
};
|
|
|
|
type SettingsSelectorState = { device: AppType; settings: SettingsMap };
|
|
const deviceSelector = (state: SettingsSelectorState) => state.device;
|
|
const settingsSelector = (state: SettingsSelectorState) => state.settings;
|
|
|
|
export const settingsSections = createSelector(
|
|
deviceSelector,
|
|
settingsSelector,
|
|
(device, settings) => {
|
|
const keys = Setting.keys(true, device);
|
|
const metadatas = [];
|
|
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
if (!Setting.isPublic(key)) continue;
|
|
|
|
const md = Setting.settingMetadata(key);
|
|
if (md.show && !md.show(settings)) continue;
|
|
|
|
metadatas.push(md);
|
|
}
|
|
|
|
const output = Setting.groupMetadatasBySections(metadatas);
|
|
|
|
if (device === AppType.Desktop || device === AppType.Cli) {
|
|
output.push({
|
|
name: 'encryption',
|
|
metadatas: [],
|
|
isScreen: true,
|
|
});
|
|
|
|
output.push({
|
|
name: 'server',
|
|
metadatas: [],
|
|
isScreen: true,
|
|
});
|
|
|
|
output.push({
|
|
name: 'keymap',
|
|
metadatas: [],
|
|
isScreen: true,
|
|
});
|
|
} else {
|
|
output.push(...([
|
|
'tools', 'importOrExport', 'moreInfo',
|
|
].map((name): SettingMetadataSection => {
|
|
return {
|
|
name,
|
|
metadatas: [],
|
|
};
|
|
})));
|
|
}
|
|
|
|
// Ideally we would also check if the user was able to synchronize
|
|
// but we don't have a way of doing that besides making a request to Joplin Cloud
|
|
const syncTargetIsJoplinCloud = settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud');
|
|
if (syncTargetIsJoplinCloud) {
|
|
output.push({
|
|
name: 'joplinCloud',
|
|
metadatas: [],
|
|
isScreen: true,
|
|
});
|
|
}
|
|
|
|
const order = Setting.sectionOrder();
|
|
|
|
const sortOrderFor = (section: SettingMetadataSection) => {
|
|
if (section.source === SettingSectionSource.Plugin) {
|
|
// Plugins should go after all other sections
|
|
return order.length + 1;
|
|
}
|
|
|
|
return order.indexOf(section.name);
|
|
};
|
|
|
|
output.sort((a, b) => {
|
|
const o1 = sortOrderFor(a);
|
|
const o2 = sortOrderFor(b);
|
|
|
|
if (o1 === o2) {
|
|
const l1 = Setting.sectionNameToLabel(a.name);
|
|
const l2 = Setting.sectionNameToLabel(b.name);
|
|
|
|
return l1.toLowerCase() < l2.toLowerCase() ? -1 : +1;
|
|
}
|
|
|
|
return o1 - o2;
|
|
});
|
|
|
|
return output;
|
|
},
|
|
);
|
|
|
|
export const settingsToComponents2 = (
|
|
comp: ConfigScreenComponent, device: AppType, settings: SettingsMap, selectedSectionName = '',
|
|
) => {
|
|
const sectionComps: ReactNode[] = [];
|
|
const sections = settingsSections({ device, settings });
|
|
|
|
for (let i = 0; i < sections.length; i++) {
|
|
const section = sections[i];
|
|
const sectionComp = comp.sectionToComponent(section.name, section, settings, selectedSectionName === section.name);
|
|
if (!sectionComp) continue;
|
|
sectionComps.push(sectionComp);
|
|
}
|
|
|
|
return sectionComps;
|
|
};
|
|
|
|
export const restartMessage = () => _('The application must be restarted for these changes to take effect.');
|
|
|
|
export const onSettingButtonPress = async (comp: ConfigScreenComponent, metadata: SettingItem) => {
|
|
const key = metadata.key;
|
|
|
|
if (key === 'sync.10.connect') {
|
|
await openJoplinOAuthLogin();
|
|
} else if (key === 'sync.clearLocalSyncStateButton') {
|
|
if (!await shim.showConfirmationDialog('This cannot be undone. Do you want to continue?')) return;
|
|
Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalSyncState);
|
|
await Setting.saveAll();
|
|
await shim.restartApp();
|
|
} else if (key === 'sync.clearLocalDataButton') {
|
|
if (!await shim.showConfirmationDialog('This cannot be undone. Do you want to continue?')) return;
|
|
Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalData);
|
|
await Setting.saveAll();
|
|
await shim.restartApp();
|
|
} else if (key === 'ocr.clearLanguageDataCacheButton') {
|
|
if (!await shim.showConfirmationDialog(restartMessage())) return;
|
|
Setting.setValue('ocr.clearLanguageDataCache', true);
|
|
await Setting.saveAll();
|
|
await shim.restartApp();
|
|
} else if (key === 'ai.usage.resetButton') {
|
|
if (!await shim.showConfirmationDialog(_('Reset AI token usage counters?'))) return;
|
|
Setting.setValue('ai.usage.inputTokens', 0);
|
|
Setting.setValue('ai.usage.outputTokens', 0);
|
|
await Setting.saveAll();
|
|
} else if (key === 'ai.chat.testButton') {
|
|
await checkAiConfig(comp);
|
|
} else if (key === 'sync.openSyncWizard') {
|
|
await CommandService.instance().execute('openSyncWizard');
|
|
} else {
|
|
const metadata = Setting.settingMetadata(key);
|
|
if (metadata.onClick) {
|
|
await metadata.onClick();
|
|
} else {
|
|
throw new Error(`Unhandled key: ${key}`);
|
|
}
|
|
}
|
|
};
|