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,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) => ``,
|
||||
).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);
|
||||
在新工单中引用
屏蔽一个用户