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
一些检查失败了
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
这个提交包含在:
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户