feat: Initial clean release of CHM
一些检查失败了
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

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -0,0 +1,67 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { StyleSheet } from 'react-native';
import { themeStyle } from '../../global-style';
import { useMemo, useState } from 'react';
import { Searchbar } from 'react-native-paper';
interface Props {
themeId: number;
value: string;
autoFocus: boolean;
placeholder?: string;
onChangeText: (text: string)=> void;
onClearButtonPress: ()=> void;
onSubmitEditing?: ()=> void;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
searchContainer: {
borderWidth: 2,
borderColor: theme.dividerColor,
backgroundColor: theme.backgroundColor,
margin: theme.margin,
borderRadius: theme.borderRadius,
},
searchTextInput: {
color: theme.color,
fontSize: theme.fontSize,
},
});
}, [themeId]);
};
const SearchBar: React.FC<Props> = ({ themeId, value, autoFocus, placeholder, onChangeText, onClearButtonPress, onSubmitEditing }) => {
const [isFocused, setIsFocused] = useState(false);
const theme = themeStyle(themeId);
const styles = useStyles(themeId);
return (
<Searchbar
style={[styles.searchContainer, isFocused && { borderColor: theme.color4 }]}
inputStyle={styles.searchTextInput}
autoFocus={autoFocus}
autoCapitalize='none'
autoComplete='off'
autoCorrect={false}
clearAccessibilityLabel={_('Clear')}
clearIcon='close'
iconColor={theme.colorFaded}
onBlur={() => setIsFocused(false)}
onChangeText={onChangeText}
onClearIconPress={onClearButtonPress}
onFocus={() => setIsFocused(true)}
onSubmitEditing={onSubmitEditing}
placeholder={placeholder ?? _('Search')}
placeholderTextColor={theme.colorFaded}
value={value}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
/>
);
};
export default SearchBar;

查看文件

@@ -0,0 +1,54 @@
import * as React from 'react';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
import Note from '@joplin/lib/models/Note';
import { render, screen } from '../../../utils/testing/testingLibrary';
import SearchResults from './SearchResults';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import Folder from '@joplin/lib/models/Folder';
import TestProviderStack from '../../testing/TestProviderStack';
const createNotes = async (count: number) => {
const folder = await Folder.save({ title: 'Test Note' });
for (let i = 0; i < count; i++) {
await Note.save({ title: `abcd ${i}`, body: 'body', parent_id: folder.id });
}
await SearchEngine.instance().syncTables();
};
let store: Store<AppState>;
interface WrapperProps {
query: string;
paused: boolean;
}
const WrappedSearchResults: React.FC<WrapperProps> = props => (
<TestProviderStack store={store}>
<SearchResults paused={props.paused} query={props.query} onHighlightedWordsChange={() => { }} ftsEnabled={1} />
</TestProviderStack>
);
describe('SearchResults', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
store = createMockReduxStore();
setupGlobalStore(store);
});
test('should show results when unpaused', async () => {
const noteCount = 8;
await createNotes(noteCount);
const { unmount } = render(<WrappedSearchResults query='abcd' paused={false}/>);
const items = await screen.findAllByText(/abcd \d\d?\d?/);
expect(items.length).toBe(noteCount);
// Unmount early to prevent state changes after the test finishes.
unmount();
});
});

查看文件

@@ -0,0 +1,129 @@
import * as React from 'react';
import { FlatList, View } from 'react-native';
import NoteItem from '../../NoteItem';
import { useEffect, useRef, useState } from 'react';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { NoteEntity } from '@joplin/lib/services/database/types';
import SearchEngineUtils from '@joplin/lib/services/search/SearchEngineUtils';
import Note from '@joplin/lib/models/Note';
import SearchEngine, { ComplexTerm } from '@joplin/lib/services/search/SearchEngine';
import { ProgressBar } from 'react-native-paper';
import shim from '@joplin/lib/shim';
interface Props {
query: string;
paused: boolean;
onHighlightedWordsChange: (highlightedWords: (ComplexTerm | string)[])=> void;
ftsEnabled: number;
}
const useResults = (props: Props) => {
const [notes, setNotes] = useState<NoteEntity[]>([]);
const [highlightedWord, setHighlightedWord] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const query = props.query;
const ftsEnabled = props.ftsEnabled;
useQueuedAsyncEffect(async (event) => {
let notes: NoteEntity[] = [];
setIsProcessing(true);
try {
if (query && !props.paused) {
if (ftsEnabled) {
const r = await SearchEngineUtils.notesForQuery(query, true, { appendWildCards: true });
notes = r.notes;
} else {
const p = query.split(' ');
const temp = [];
for (let i = 0; i < p.length; i++) {
const t = p[i].trim();
if (!t) continue;
temp.push(t);
}
notes = await Note.previews(null, {
anywherePattern: `*${temp.join('*')}*`,
});
}
}
if (event.cancelled) return;
const parsedQuery = await SearchEngine.instance().parseQuery(query);
const searchTerms = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
if (event.cancelled) return;
props.onHighlightedWordsChange(searchTerms);
setHighlightedWord(SearchEngine.instance().createQueryFromTerms(searchTerms));
setNotes(notes);
} finally {
setIsProcessing(false);
}
}, [query, props.paused, ftsEnabled], { interval: 200 });
return {
notes,
highlightedWord,
isPending: isProcessing,
};
};
const useIsLongRunning = (isPending: boolean) => {
const [isLongRunning, setIsLongRunning] = useState(false);
const isPendingRef = useRef(isPending);
isPendingRef.current = isPending;
type TimeoutType = ReturnType<typeof shim.setTimeout>;
const timeoutRef = useRef<TimeoutType|null>(null);
useEffect(() => {
if (timeoutRef.current !== null) {
shim.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (isPending) {
const longRunningTimeout = 1000;
timeoutRef.current = shim.setTimeout(() => {
timeoutRef.current = null;
setIsLongRunning(isPendingRef.current);
}, longRunningTimeout);
} else {
setIsLongRunning(false);
}
}, [isPending]);
return isLongRunning;
};
const containerStyle = { flex: 1 };
const SearchResults: React.FC<Props> = props => {
const { notes, highlightedWord, isPending } = useResults(props);
// Don't show the progress bar immediately, only show if the search
// is taking some time.
const longRunning = useIsLongRunning(isPending);
const progressVisible = longRunning;
// To have the correct height on web, the progress bar needs to be wrapped:
const progressBar = <View aria-hidden={!progressVisible}>
<ProgressBar indeterminate={true} visible={progressVisible}/>
</View>;
return (
<View style={containerStyle}>
{progressBar}
<FlatList
data={notes}
extraData={highlightedWord}
keyExtractor={(item) => item.id}
renderItem={event => <NoteItem note={event.item} highlightedWord={highlightedWord} />}
/>
</View>
);
};
export default SearchResults;

查看文件

@@ -0,0 +1,132 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import { connect } from 'react-redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import { ThemeStyle, themeStyle } from '../../global-style';
import { AppState } from '../../../utils/types';
import { Dispatch } from 'redux';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import SearchResults from './SearchResults';
import AccessibleView from '../../accessibility/AccessibleView';
import { ComplexTerm } from '@joplin/lib/services/search/SearchEngine';
import SearchBar from './SearchBar';
interface Props {
themeId: number;
query: string;
visible: boolean;
dispatch: Dispatch;
noteSelectionEnabled: boolean;
ftsEnabled: number;
}
const useStyles = (theme: ThemeStyle, visible: boolean) => {
return useMemo(() => {
return StyleSheet.create({
body: {
flex: 1,
},
rootStyle: visible ? theme.rootStyle : theme.hiddenRootStyle,
});
}, [theme, visible]);
};
// Workaround for https://github.com/laurent22/joplin/issues/12823:
// Disable search-as-you-type for short 0-2 character searches that
// are likely to match the start of a large number of words.
const useSearchPaused = (query: string) => {
const [pauseDisabled, setPauseDisabled] = useState(false);
// Only disable search-as-you-type for a subset of all characters.
// This is, for example, to ensure that search-as-you-type remains
// enabled for CJK characters (e.g. U+6570 has length 1).
const paused = query.match(/^[a-z0-9]{0,2}$/i);
const onOverridePause = useCallback(() => {
setPauseDisabled(true);
}, []);
useEffect(() => {
setPauseDisabled(false);
}, [query]);
return {
paused: paused && !pauseDisabled,
onOverridePause,
};
};
const SearchScreenComponent: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(theme, props.visible);
const [query, setQuery] = useState(props.query);
const { paused, onOverridePause } = useSearchPaused(query);
const globalQueryRef = useRef(props.query);
globalQueryRef.current = props.query;
useEffect(() => {
if (globalQueryRef.current !== query) {
props.dispatch({
type: 'SEARCH_QUERY',
query,
});
}
}, [props.dispatch, query]);
const clearButton_press = useCallback(() => {
setQuery('');
}, []);
const onHighlightedWordsChange = useCallback((words: (ComplexTerm | string)[]) => {
props.dispatch({
type: 'SET_HIGHLIGHTED',
words,
});
}, [props.dispatch]);
return (
<AccessibleView style={styles.rootStyle} inert={!props.visible}>
<ScreenHeader
title={_('Search')}
folderPickerOptions={{
visible: props.noteSelectionEnabled,
mustSelect: true,
}}
showSideMenuButton={false}
showSearchButton={false}
/>
<View style={styles.body}>
<SearchBar
themeId={props.themeId}
autoFocus={props.visible}
value={query}
onChangeText={setQuery}
onSubmitEditing={onOverridePause}
onClearButtonPress={clearButton_press}
placeholder={_('Search notes, notebooks...')}
/>
<SearchResults
query={query}
paused={paused}
ftsEnabled={props.ftsEnabled}
onHighlightedWordsChange={onHighlightedWordsChange}
/>
</View>
</AccessibleView>
);
};
const SearchScreen = connect((state: AppState) => {
return {
query: state.searchQuery,
themeId: state.settings.theme,
settings: state.settings,
noteSelectionEnabled: state.noteSelectionEnabled,
ftsEnabled: state.settings['db.ftsEnabled'],
};
})(SearchScreenComponent);
export default SearchScreen;