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,176 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import { connect } from 'react-redux';
import { AppState } from '../../../utils/types';
import { useCallback, useMemo, useState } from 'react';
import { themeStyle } from '../../global-style';
import CameraViewMultiPage from '../../CameraView/CameraViewMultiPage';
import { Dispatch } from 'redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import { CameraResult } from '../../CameraView/types';
import NotePreview, { CreateNoteEvent } from './NotePreview';
import shim from '@joplin/lib/shim';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import { Portal, ProgressBar, Snackbar } from 'react-native-paper';
import useBackHandler from '../../../utils/hooks/useBackHandler';
import Logger from '@joplin/utils/Logger';
import NavService from '@joplin/lib/services/NavService';
import { ResourceOcrDriverId, ResourceOcrStatus } from '@joplin/lib/services/database/types';
const logger = Logger.create('DocumentScanner');
interface Props {
dispatch: Dispatch;
themeId: number;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
root: theme.rootStyle,
noRemainingPhotosContainer: {
margin: theme.margin,
gap: theme.margin,
},
progressBarContainer: {
flexGrow: 0,
},
});
}, [themeId]);
};
const DocumentScanner: React.FC<Props> = ({ themeId, dispatch }) => {
const styles = useStyles(themeId);
const [cameraVisible, setCameraVisible] = useState(true);
const [photos, setPhotos] = useState<CameraResult[]>([]);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [creatingNote, setCreatingNote] = useState(false);
useBackHandler(() => {
if (photos.length && !cameraVisible) {
setCameraVisible(true);
return true;
}
return false;
});
const onDeleteLastPhoto = useCallback(() => {
if (photos.length <= 1) {
setCameraVisible(true);
}
setSnackbarMessage('');
setPhotos(photos => {
const result = [...photos];
result.pop();
return result;
});
}, [photos]);
const onCloseScreen = useCallback(() => {
setPhotos([]);
dispatch({ type: 'NAV_BACK' });
}, [dispatch]);
const onCreateNote = useCallback(async (event: CreateNoteEvent) => {
setSnackbarMessage(_('Creating note "%s"...', event.title));
setCreatingNote(true);
logger.info('Creating note', event.queueForTranscription ? '(with transcription)' : '');
try {
const resources = [];
for (const image of photos) {
resources.push(await shim.createResourceFromPath(
image.uri,
{
...(event.queueForTranscription ? {
ocr_status: ResourceOcrStatus.Todo,
ocr_driver_id: ResourceOcrDriverId.HandwrittenText,
ocr_details: '',
ocr_error: '',
ocr_text: '',
} : {}),
title: event.title,
mime: image.type,
},
));
}
const note = await Note.save({
title: event.title,
body: resources.map(
(image, index) => `![${_('Photo %d', index + 1)}](:/${image.id})`,
).join('\n\n'),
parent_id: event.parentId,
});
await Tag.setNoteTagsByTitles(note.id, event.tags);
await NavService.go('Note', { noteId: note.id });
} catch (error) {
logger.error('Error creating note', error);
await shim.showErrorDialog(`Failed to create note: ${error}`);
} finally {
setCreatingNote(false);
}
}, [photos]);
const onDismissSnackbar = useCallback(() => {
setSnackbarMessage('');
}, []);
const onHideCamera = useCallback(() => {
setCameraVisible(false);
}, []);
const renderContent = () => {
if (cameraVisible) {
return <CameraViewMultiPage
themeId={themeId}
onCancel={onCloseScreen}
onComplete={onHideCamera}
photos={photos}
onSetPhotos={setPhotos}
onInsertBarcode={()=>{}}
/>;
} else if (photos.length > 0) {
return <>
<ScreenHeader title={_('Note preview')} onDeleteButtonPress={onDeleteLastPhoto}/>
{creatingNote && <View style={styles.progressBarContainer}>
<ProgressBar visible indeterminate aria-label={_('Creating note.')}/>
</View>}
<NotePreview
imageCount={photos.length}
lastImage={photos[photos.length - 1]}
onCreateNote={creatingNote ? null : onCreateNote}
/>
</>;
} else {
// Error/loading state
return <>
<ScreenHeader title={'Document scanner'}/>
</>;
}
};
return <View style={styles.root}>
{renderContent()}
<Portal>
<Snackbar
key={`snackbar--${snackbarMessage}`}
visible={!!snackbarMessage}
onDismiss={onDismissSnackbar}
action={{
label: _('Dismiss'),
onPress: onDismissSnackbar,
}}
>{snackbarMessage}</Snackbar>
</Portal>
</View>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
}))(DocumentScanner);

查看文件

@@ -0,0 +1,222 @@
import * as React from 'react';
import { useMemo, useState, useCallback, useEffect } from 'react';
import { themeStyle } from '../../global-style';
import { ScrollView, StyleSheet, TextStyle, View } from 'react-native';
import { CameraResult } from '../../CameraView/types';
import TextInput from '../../TextInput';
import PhotoPreview from '../../CameraView/PhotoPreview';
import TagEditor, { TagEditorMode } from '../../TagEditor';
import { AppState } from '../../../utils/types';
import { connect } from 'react-redux';
import { FolderEntity, TagEntity } from '@joplin/lib/services/database/types';
import { _ } from '@joplin/lib/locale';
import FolderPicker from '../../FolderPicker';
import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
import { formatMsToLocal } from '@joplin/utils/time';
import { PrimaryButton } from '../../buttons';
import { Switch, Text } from 'react-native-paper';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
export interface CreateNoteEvent {
title: string;
tags: string[];
parentId: string;
queueForTranscription: boolean;
}
type OnCreateNote = (event: CreateNoteEvent)=> void;
interface Props {
themeId: number;
imageCount: number;
lastImage: CameraResult;
allTags: TagEntity[];
allFolders: FolderEntity[];
selectedFolderId: string;
isJoplinServer: boolean;
queueForTranscriptionDefault: boolean;
onCreateNote: null|OnCreateNote;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
const headerStyle: TextStyle = {
...theme.headerStyle,
fontSize: theme.fontSize,
fontWeight: 'normal',
};
return StyleSheet.create({
titleInput: {
color: theme.color,
fontSize: theme.fontSize,
},
rootScrollView: {
flex: 1,
width: '100%',
maxWidth: 700,
alignSelf: 'center',
},
photoBackground: {
},
photoLabel: {
},
tagEditor: {
marginHorizontal: theme.margin,
},
tagEditorHeader: headerStyle,
folderPickerLine: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: theme.margin * 2,
margin: theme.margin,
marginBottom: theme.margin * 2,
},
folderPicker: {
flexGrow: 1,
},
actionButton: {
alignSelf: 'flex-end',
margin: theme.margin,
},
transcriptionCheckboxContainer: {
display: 'flex',
flexDirection: 'column',
marginHorizontal: theme.margin,
marginTop: theme.margin * 2,
marginBottom: theme.margin * 2,
gap: 6,
},
transcriptionCheckbox: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
transcriptionLabel: headerStyle,
transcriptionHelp: {
color: theme.colorFaded,
gap: 8,
},
});
}, [themeId]);
};
const tagSearchResultsProps = {
// Required on Android when including one <ScrollView> inside another:
nestedScrollEnabled: true,
};
const NotePreview: React.FC<Props> = ({
themeId, lastImage, imageCount, allTags, onCreateNote, allFolders, selectedFolderId: propsSelectedFolderId, isJoplinServer, queueForTranscriptionDefault,
}) => {
const styles = useStyles(themeId);
const [title, setTitle] = useState('');
const [tags, setTags] = useState([]);
const [selectedFolderId, setSelectedFolderId] = useState(propsSelectedFolderId);
const [queueForTranscription, setQueueForTranscription] = useState(queueForTranscriptionDefault);
const realFolders = useMemo(() => {
return Folder.getRealFolders(allFolders);
}, [allFolders]);
useEffect(() => {
// Don't allow selecting a virtual folder
if (selectedFolderId && realFolders.every(folder => folder.id !== selectedFolderId)) {
setSelectedFolderId('');
}
}, [realFolders, selectedFolderId]);
useEffect(() => {
const template = Setting.value('scanner.titleTemplate');
const date = formatMsToLocal(Date.now(), Setting.value('dateFormat'));
setTitle(
template.replace(/{date}/g, date)
.replace(/{count}/g, `${imageCount}`),
);
}, [imageCount]);
const onNewNote = useCallback(() => {
if (!onCreateNote) return;
Setting.setValue('scanner.requestTranscription', queueForTranscription);
onCreateNote({
tags,
title,
parentId: selectedFolderId ?? '',
queueForTranscription,
});
}, [onCreateNote, tags, title, selectedFolderId, queueForTranscription]);
const onNewFolder = useCallback(async (title: string) => {
const folder = await Folder.save({ title });
setSelectedFolderId(folder.id);
}, []);
const transcriptionCheckbox = <View style={styles.transcriptionCheckboxContainer}>
<View style={styles.transcriptionCheckbox}>
<Text nativeID='transcriptionLabel' style={styles.transcriptionLabel}>{_('Recognise text:')}</Text>
<Switch accessibilityLabelledBy='transcriptionLabel' value={queueForTranscription} onValueChange={setQueueForTranscription} />
</View>
<View>
<Text style={styles.transcriptionHelp}>{
_('When enabled, requests that the images in the note be transcribed with a higher-quality on-server transcription service. Requires sync with a copy of the desktop app.')
}</Text>
</View>
</View>;
return <ScrollView style={styles.rootScrollView}>
<TextInput
style={styles.titleInput}
themeId={themeId}
value={title}
onChangeText={setTitle}
/>
<View style={styles.folderPickerLine}>
<PhotoPreview
source={lastImage}
backgroundStyle={styles.photoBackground}
textStyle={styles.photoLabel}
label={imageCount}
/>
<FolderPicker
themeId={themeId}
placeholder={_('Select notebook')}
folders={realFolders}
onValueChange={setSelectedFolderId}
selectedFolderId={selectedFolderId}
mustSelect={true}
onNewFolder={onNewFolder}
/>
</View>
<TagEditor
themeId={themeId}
tags={tags}
mode={TagEditorMode.Compact}
allTags={allTags}
style={styles.tagEditor}
onTagsChange={setTags}
headerStyle={styles.tagEditorHeader}
searchResultProps={tagSearchResultsProps}
/>
{isJoplinServer ? transcriptionCheckbox : null}
<PrimaryButton
onPress={onNewNote}
style={styles.actionButton}
disabled={!onCreateNote}
>{_('Create note')}</PrimaryButton>
</ScrollView>;
};
export default connect((state: AppState) => ({
allTags: state.tags,
allFolders: state.folders,
selectedFolderId: state.selectedFolderId,
isJoplinServer: SyncTargetRegistry.isJoplinServerOrCloud(state.settings['sync.target']),
themeId: state.settings.theme,
queueForTranscriptionDefault: state.settings['scanner.requestTranscription'],
}))(NotePreview);