mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Mobile: Resolves #285: Create, edit and remove tags from notes
This commit is contained in:
parent
544f93bf22
commit
aabb9be7de
@ -398,11 +398,10 @@ class BaseModel {
|
||||
}
|
||||
|
||||
output = this.filter(o);
|
||||
} catch (error) {
|
||||
this.logger().error('Cannot save model', error);
|
||||
} finally {
|
||||
this.releaseSaveMutex(o, mutexRelease);
|
||||
}
|
||||
|
||||
this.releaseSaveMutex(o, mutexRelease);
|
||||
|
||||
|
||||
return output;
|
||||
}
|
||||
|
@ -46,6 +46,13 @@ globalStyle.lineInput = {
|
||||
backgroundColor: globalStyle.backgroundColor,
|
||||
};
|
||||
|
||||
globalStyle.buttonRow = {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: globalStyle.dividerColor,
|
||||
paddingTop: 10,
|
||||
};
|
||||
|
||||
let themeCache_ = {};
|
||||
|
||||
function themeStyle(theme) {
|
||||
|
@ -128,6 +128,8 @@ class ScreenHeaderComponent extends Component {
|
||||
color: theme.raisedHighlightedColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.fontSize,
|
||||
paddingTop: 15,
|
||||
paddingBottom: 15,
|
||||
},
|
||||
warningBox: {
|
||||
backgroundColor: "#ff9900",
|
||||
@ -428,15 +430,19 @@ class ScreenHeaderComponent extends Component {
|
||||
</TouchableOpacity>
|
||||
) : null;
|
||||
|
||||
const showSideMenuButton = this.props.showSideMenuButton !== false && !this.props.noteSelectionEnabled;
|
||||
const showSearchButton = this.props.showSearchButton !== false && !this.props.noteSelectionEnabled;
|
||||
const showContextMenuButton = this.props.showContextMenuButton !== false;
|
||||
|
||||
const titleComp = createTitleComponent();
|
||||
const sideMenuComp = this.props.noteSelectionEnabled ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
|
||||
const sideMenuComp = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
|
||||
const backButtonComp = backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack);
|
||||
const searchButtonComp = this.props.noteSelectionEnabled ? null : searchButton(this.styles(), () => this.searchButton_press());
|
||||
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press());
|
||||
const deleteButtonComp = this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press()) : null;
|
||||
const sortButtonComp = this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
|
||||
const windowHeight = Dimensions.get('window').height - 50;
|
||||
|
||||
const menuComp = (
|
||||
const menuComp = !showContextMenuButton ? null : (
|
||||
<Menu onSelect={(value) => this.menu_select(value)} style={this.styles().contextMenu}>
|
||||
<MenuTrigger style={{ paddingTop: PADDING_V, paddingBottom: PADDING_V }}>
|
||||
<Text style={this.styles().contextMenuTrigger}> ⋮</Text>
|
||||
|
201
ReactNativeClient/lib/components/screens/note-tags.js
Normal file
201
ReactNativeClient/lib/components/screens/note-tags.js
Normal file
@ -0,0 +1,201 @@
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { ListView, StyleSheet, View, Text, Button, FlatList, TouchableOpacity, TextInput } = require('react-native');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const { connect } = require('react-redux');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { time } = require('lib/time-utils');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const { ReportService } = require('lib/services/report.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: {
|
||||
flex: 1,
|
||||
margin: globalStyle.margin,
|
||||
},
|
||||
});
|
||||
|
||||
class NoteTagsScreenComponent extends BaseScreenComponent {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
this.state = {
|
||||
noteTagIds: [],
|
||||
noteId: null,
|
||||
tagListData: [],
|
||||
newTags: '',
|
||||
savingTags: false,
|
||||
};
|
||||
|
||||
const noteHasTag = (tagId) => {
|
||||
for (let i = 0; i < this.state.tagListData.length; i++) {
|
||||
if (this.state.tagListData[i].id === tagId) return this.state.tagListData[i].selected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const newTagTitles = () => {
|
||||
return this.state.newTags
|
||||
.split(',')
|
||||
.map(t => t.trim().toLowerCase())
|
||||
.filter(t => !!t);
|
||||
}
|
||||
|
||||
this.tag_press = (tagId) => {
|
||||
const newData = this.state.tagListData.slice();
|
||||
for (let i = 0; i < newData.length; i++) {
|
||||
const t = newData[i];
|
||||
if (t.id === tagId) {
|
||||
const newTag = Object.assign({}, t);
|
||||
newTag.selected = !newTag.selected;
|
||||
newData[i] = newTag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ tagListData: newData });
|
||||
}
|
||||
|
||||
this.renderTag = (data) => {
|
||||
const tag = data.item;
|
||||
const iconName = noteHasTag(tag.id) ? 'md-checkbox-outline' : 'md-square-outline';
|
||||
return (
|
||||
<TouchableOpacity key={tag.id} onPress={() => this.tag_press(tag.id)} style={this.styles().tag}>
|
||||
<View style={this.styles().tagIconText}>
|
||||
<Icon name={iconName} style={this.styles().tagCheckbox}/><Text>{tag.title}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
this.tagKeyExtractor = (tag, index) => tag.id;
|
||||
|
||||
this.okButton_press = async () => {
|
||||
this.setState({ savingTags: true });
|
||||
|
||||
try {
|
||||
const tagIds = this.state.tagListData.filter(t => t.selected).map(t => t.id);
|
||||
await Tag.setNoteTagsByIds(this.state.noteId, tagIds);
|
||||
|
||||
const extraTitles = newTagTitles();
|
||||
for (let i = 0; i < extraTitles.length; i++) {
|
||||
await Tag.addNoteTagByTitle(this.state.noteId, extraTitles[i]);
|
||||
}
|
||||
} finally {
|
||||
this.setState({ savingTags: false });
|
||||
}
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_BACK',
|
||||
});
|
||||
}
|
||||
|
||||
this.cancelButton_press = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_BACK',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const noteId = this.props.noteId;
|
||||
this.setState({ noteId: noteId });
|
||||
this.loadNoteTags(noteId);
|
||||
}
|
||||
|
||||
async loadNoteTags(noteId) {
|
||||
const tags = await Tag.tagsByNoteId(noteId);
|
||||
const tagIds = tags.map(t => t.id);
|
||||
|
||||
const tagListData = this.props.tags.map(tag => { return {
|
||||
id: tag.id,
|
||||
title: tag.title,
|
||||
selected: tagIds.indexOf(tag.id) >= 0,
|
||||
}});
|
||||
|
||||
this.setState({ tagListData: tagListData });
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
tag: {
|
||||
padding: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
},
|
||||
tagIconText: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tagCheckbox: {
|
||||
marginRight: 5,
|
||||
fontSize: 20,
|
||||
},
|
||||
newTagBox: {
|
||||
flexDirection:'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.dividerColor
|
||||
},
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader title={_('Note tags')} showSideMenuButton={false} showSearchButton={false} showContextMenuButton={false}/>
|
||||
<FlatList
|
||||
data={this.state.tagListData}
|
||||
renderItem={this.renderTag}
|
||||
keyExtractor={this.tagKeyExtractor}
|
||||
/>
|
||||
<View style={this.styles().newTagBox}>
|
||||
<Text>{_('Or type tags:')}</Text><TextInput value={this.state.newTags} onChangeText={value => { this.setState({ newTags: value }) }} style={{flex:1}}/>
|
||||
</View>
|
||||
<View style={theme.buttonRow}>
|
||||
<View style={{flex:1}}>
|
||||
<Button disabled={this.state.savingTags} title={_('OK')} onPress={this.okButton_press}></Button>
|
||||
</View>
|
||||
<View style={{flex:1, marginLeft: 5}}>
|
||||
<Button disabled={this.state.savingTags} title={_('Cancel')} onPress={this.cancelButton_press}></Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const NoteTagsScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
tags: state.tags,
|
||||
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
||||
};
|
||||
}
|
||||
)(NoteTagsScreenComponent)
|
||||
|
||||
module.exports = { NoteTagsScreen };
|
@ -357,6 +357,16 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
shared.toggleIsTodo_onPress(this);
|
||||
}
|
||||
|
||||
tags_onPress() {
|
||||
if (!this.state.note || !this.state.note.id) return;
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'NoteTags',
|
||||
noteId: this.state.note.id,
|
||||
});
|
||||
}
|
||||
|
||||
setAlarm_onPress() {
|
||||
this.setState({ alarmDialogShown: true });
|
||||
}
|
||||
@ -393,6 +403,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
menuOptions() {
|
||||
const note = this.state.note;
|
||||
const isTodo = note && !!note.is_todo;
|
||||
const isSaved = note && note.id;
|
||||
|
||||
let output = [];
|
||||
|
||||
@ -410,6 +421,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
output.push({ title: _('Set alarm'), onPress: () => { this.setState({ alarmDialogShown: true }) }});;
|
||||
}
|
||||
|
||||
if (isSaved) output.push({ title: _('Tags'), onPress: () => { this.tags_onPress(); } });
|
||||
output.push({ title: isTodo ? _('Convert to note') : _('Convert to todo'), onPress: () => { this.toggleIsTodo_onPress(); } });
|
||||
output.push({ isDivider: true });
|
||||
if (this.props.showAdvancedOptions) output.push({ title: this.state.showNoteMetadata ? _('Hide metadata') : _('Show metadata'), onPress: () => { this.showMetadata_onPress(); } });
|
||||
|
@ -12,6 +12,7 @@ class Database {
|
||||
this.driver_ = driver;
|
||||
this.logger_ = new Logger();
|
||||
this.logExcludedQueryTypes_ = [];
|
||||
this.batchTransactionMutex_ = new Mutex();
|
||||
}
|
||||
|
||||
setLogExcludedQueryTypes(v) {
|
||||
@ -113,16 +114,20 @@ class Database {
|
||||
}
|
||||
|
||||
// There can be only one transaction running at a time so use a mutex
|
||||
const release = await Database.batchTransactionMutex_.acquire();
|
||||
const release = await this.batchTransactionMutex_.acquire();
|
||||
|
||||
try {
|
||||
queries.splice(0, 0, 'BEGIN TRANSACTION');
|
||||
queries.push('COMMIT'); // Note: ROLLBACK is currently not supported
|
||||
await this.exec('BEGIN TRANSACTION');
|
||||
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
let query = this.wrapQuery(queries[i]);
|
||||
await this.exec(query.sql, query.params);
|
||||
}
|
||||
|
||||
await this.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await this.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
@ -300,8 +305,6 @@ class Database {
|
||||
|
||||
}
|
||||
|
||||
Database.batchTransactionMutex_ = new Mutex();
|
||||
|
||||
Database.TYPE_UNKNOWN = 0;
|
||||
Database.TYPE_INT = 1;
|
||||
Database.TYPE_TEXT = 2;
|
||||
|
@ -38,7 +38,7 @@ class NoteResource extends BaseModel {
|
||||
const missingResources = await this.db().selectAll('SELECT id FROM resources WHERE id NOT IN (SELECT DISTINCT resource_id FROM note_resources)');
|
||||
const queries = [];
|
||||
for (let i = 0; i < missingResources.length; i++) {
|
||||
const id = missingResources[i];
|
||||
const id = missingResources[i].id;
|
||||
queries.push({ sql: 'INSERT INTO note_resources (note_id, resource_id, is_associated, last_seen_time) VALUES (?, ?, ?, ?)', params: ["", id, 0, Date.now()] });
|
||||
}
|
||||
await this.db().transactionExecBatch(queries);
|
||||
|
@ -106,6 +106,12 @@ class Tag extends BaseItem {
|
||||
return this.loadByField('title', title, { caseInsensitive: true });
|
||||
}
|
||||
|
||||
static async addNoteTagByTitle(noteId, tagTitle) {
|
||||
let tag = await this.loadByTitle(tagTitle);
|
||||
if (!tag) tag = await Tag.save({ title: tagTitle }, { userSideValidation: true });
|
||||
return await this.addNote(tag.id, noteId);
|
||||
}
|
||||
|
||||
static async setNoteTagsByTitles(noteId, tagTitles) {
|
||||
const previousTags = await this.tagsByNoteId(noteId);
|
||||
const addedTitles = [];
|
||||
@ -126,6 +132,23 @@ class Tag extends BaseItem {
|
||||
}
|
||||
}
|
||||
|
||||
static async setNoteTagsByIds(noteId, tagIds) {
|
||||
const previousTags = await this.tagsByNoteId(noteId);
|
||||
const addedIds = [];
|
||||
|
||||
for (let i = 0; i < tagIds.length; i++) {
|
||||
const tagId = tagIds[i];
|
||||
await this.addNote(tagId, noteId);
|
||||
addedIds.push(tagId);
|
||||
}
|
||||
|
||||
for (let i = 0; i < previousTags.length; i++) {
|
||||
if (addedIds.indexOf(previousTags[i].id) < 0) {
|
||||
await this.removeNote(previousTags[i].id, noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async save(o, options = null) {
|
||||
if (options && options.userSideValidation) {
|
||||
if ('title' in o) {
|
||||
|
@ -32,6 +32,7 @@ const { ConfigScreen } = require('lib/components/screens/config.js');
|
||||
const { FolderScreen } = require('lib/components/screens/folder.js');
|
||||
const { LogScreen } = require('lib/components/screens/log.js');
|
||||
const { StatusScreen } = require('lib/components/screens/status.js');
|
||||
const { NoteTagsScreen } = require('lib/components/screens/note-tags.js');
|
||||
const { WelcomeScreen } = require('lib/components/screens/welcome.js');
|
||||
const { SearchScreen } = require('lib/components/screens/search.js');
|
||||
const { OneDriveLoginScreen } = require('lib/components/screens/onedrive-login.js');
|
||||
@ -127,9 +128,9 @@ const generalMiddleware = store => next => async (action) => {
|
||||
|
||||
let navHistory = [];
|
||||
|
||||
function historyCanGoBackTo(route) {
|
||||
if (route.routeName == 'Note') return false;
|
||||
if (route.routeName == 'Folder') return false;
|
||||
function historyCanGoBackTo(route, nextRoute) {
|
||||
if (route.routeName === 'Note' && nextRoute.routeName !== 'NoteTags') return false;
|
||||
if (route.routeName === 'Folder') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -172,7 +173,7 @@ const appReducer = (state = appDefaultState, action) => {
|
||||
const currentRoute = state.route;
|
||||
const currentRouteName = currentRoute ? currentRoute.routeName : '';
|
||||
|
||||
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
|
||||
if (!historyGoingBack && historyCanGoBackTo(currentRoute, action)) {
|
||||
// If the route *name* is the same (even if the other parameters are different), we
|
||||
// overwrite the last route in the history with the current one. If the route name
|
||||
// is different, we push a new history entry.
|
||||
@ -564,6 +565,7 @@ class AppComponent extends React.Component {
|
||||
Status: { screen: StatusScreen },
|
||||
Search: { screen: SearchScreen },
|
||||
Config: { screen: ConfigScreen },
|
||||
NoteTags: { screen: NoteTagsScreen },
|
||||
};
|
||||
|
||||
return (
|
||||
|
Loading…
Reference in New Issue
Block a user