1
0
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:
Laurent Cozic 2018-03-16 20:17:52 +00:00
parent 544f93bf22
commit aabb9be7de
9 changed files with 270 additions and 17 deletions

View File

@ -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;
}

View File

@ -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) {

View File

@ -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}> &#8942;</Text>

View 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 };

View File

@ -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(); } });

View File

@ -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;

View File

@ -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);

View File

@ -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) {

View File

@ -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 (