From aabb9be7de5cb9041905642bc4b5dca1f9b15407 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Fri, 16 Mar 2018 20:17:52 +0000 Subject: [PATCH] Mobile: Resolves #285: Create, edit and remove tags from notes --- ReactNativeClient/lib/BaseModel.js | 7 +- .../lib/components/global-style.js | 7 + .../lib/components/screen-header.js | 12 +- .../lib/components/screens/note-tags.js | 201 ++++++++++++++++++ .../lib/components/screens/note.js | 12 ++ ReactNativeClient/lib/database.js | 13 +- ReactNativeClient/lib/models/NoteResource.js | 2 +- ReactNativeClient/lib/models/Tag.js | 23 ++ ReactNativeClient/root.js | 10 +- 9 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 ReactNativeClient/lib/components/screens/note-tags.js diff --git a/ReactNativeClient/lib/BaseModel.js b/ReactNativeClient/lib/BaseModel.js index 44324e279..f84021bee 100644 --- a/ReactNativeClient/lib/BaseModel.js +++ b/ReactNativeClient/lib/BaseModel.js @@ -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; } diff --git a/ReactNativeClient/lib/components/global-style.js b/ReactNativeClient/lib/components/global-style.js index c407c99b5..c329796cd 100644 --- a/ReactNativeClient/lib/components/global-style.js +++ b/ReactNativeClient/lib/components/global-style.js @@ -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) { diff --git a/ReactNativeClient/lib/components/screen-header.js b/ReactNativeClient/lib/components/screen-header.js index 7f531d420..209a0955e 100644 --- a/ReactNativeClient/lib/components/screen-header.js +++ b/ReactNativeClient/lib/components/screen-header.js @@ -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 { ) : 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 : ( this.menu_select(value)} style={this.styles().contextMenu}> diff --git a/ReactNativeClient/lib/components/screens/note-tags.js b/ReactNativeClient/lib/components/screens/note-tags.js new file mode 100644 index 000000000..c34caad4e --- /dev/null +++ b/ReactNativeClient/lib/components/screens/note-tags.js @@ -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 ( + this.tag_press(tag.id)} style={this.styles().tag}> + + {tag.title} + + + ); + } + + 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 ( + + + + + {_('Or type tags:')} { this.setState({ newTags: value }) }} style={{flex:1}}/> + + + + + + + + + + + ); + } + +} + +const NoteTagsScreen = connect( + (state) => { + return { + theme: state.settings.theme, + tags: state.tags, + noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, + }; + } +)(NoteTagsScreenComponent) + +module.exports = { NoteTagsScreen }; \ No newline at end of file diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js index 56ed5ab99..9b9a53b42 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/note.js @@ -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(); } }); diff --git a/ReactNativeClient/lib/database.js b/ReactNativeClient/lib/database.js index b67118006..2359c0f83 100644 --- a/ReactNativeClient/lib/database.js +++ b/ReactNativeClient/lib/database.js @@ -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; diff --git a/ReactNativeClient/lib/models/NoteResource.js b/ReactNativeClient/lib/models/NoteResource.js index 47938b64c..10dfbb955 100644 --- a/ReactNativeClient/lib/models/NoteResource.js +++ b/ReactNativeClient/lib/models/NoteResource.js @@ -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); diff --git a/ReactNativeClient/lib/models/Tag.js b/ReactNativeClient/lib/models/Tag.js index 67f17c51a..a581da413 100644 --- a/ReactNativeClient/lib/models/Tag.js +++ b/ReactNativeClient/lib/models/Tag.js @@ -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) { diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index d4fbec995..79475b358 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -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 (