You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
25 Commits
cli-v1.0.1
...
android-v1
Author | SHA1 | Date | |
---|---|---|---|
|
9fe7f0adae | ||
|
ea1374371f | ||
|
bce4294529 | ||
|
de409b632a | ||
|
a677b2e844 | ||
|
c63bb19cb6 | ||
|
72fd77812e | ||
|
40f3e72bd1 | ||
|
d6d86f2aff | ||
|
c71809438b | ||
|
3e6e1a0a36 | ||
|
f590ce4a34 | ||
|
67608e29c8 | ||
|
d5c2982093 | ||
|
90fad2a3ab | ||
|
bc7c82e3da | ||
|
cb824f7dd7 | ||
|
32c47a96f1 | ||
|
4e3f8893f7 | ||
|
ca3946689a | ||
|
e2ad2dfcaa | ||
|
d6f7893c56 | ||
|
8c65a7cc31 | ||
|
aabb9be7de | ||
|
544f93bf22 |
@@ -10,10 +10,6 @@ Again, please check that it has not already been requested. If it has, simply **
|
||||
|
||||
# Adding new features
|
||||
|
||||
If you want to add a new feature, consider asking about it before implementing it to make sure it is within the scope of the project. Of course you are free to create the pull request directly but it is not guaranteed it is going to be accepted.
|
||||
If you want to add a new feature, consider asking about it before implementing it or checking existing discussions to make sure it is within the scope of the project. Of course you are free to create the pull request directly but it is not guaranteed it is going to be accepted.
|
||||
|
||||
Building the apps is relatively easy - please [see the build instructions](https://github.com/laurent22/joplin/blob/master/BUILD.md) for more details.
|
||||
|
||||
# Coding style
|
||||
|
||||
See the [prettier config](https://github.com/laurent22/joplin/blob/master/prettier.config.js).
|
||||
|
1383
CliClient/locales/da_DK.po
Normal file
1383
CliClient/locales/da_DK.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1330,6 +1330,12 @@ msgstr "Bestätigen"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Synchronisation abbrechen"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr "Website von Joplin"
|
||||
|
||||
|
@@ -1193,6 +1193,12 @@ msgstr ""
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr ""
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -1305,6 +1305,12 @@ msgstr "Confirmar"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Cancelar sincronización"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr "Sitio web de Joplin"
|
||||
|
||||
|
@@ -1317,6 +1317,12 @@ msgstr "Baieztatu"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Sinkronizazioa utzi"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -14,8 +14,6 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.0.3\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
msgstr "Pour supprimer une vignette, enlever là des notes associées."
|
||||
@@ -1314,6 +1312,12 @@ msgstr "Confirmer"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Annuler synchronisation"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr "Nouvelles étiquettes :"
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr "Entrez de nouvelles étiquettes ou sélectionnez de la liste"
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr "Site web de Joplin"
|
||||
|
||||
|
@@ -1297,6 +1297,12 @@ msgstr "Potvrdi"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Prekini sinkronizaciju"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -1283,6 +1283,12 @@ msgstr "Conferma"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Cancella la sincronizzazione"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -1287,6 +1287,12 @@ msgstr "確認"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "同期の中止"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -1193,6 +1193,12 @@ msgstr ""
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr ""
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -1319,6 +1319,12 @@ msgstr "Bevestig"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Annuleer synchronisatie"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -1305,6 +1305,12 @@ msgstr "Confirmar"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Cancelar sincronização"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr "Site do Joplin"
|
||||
|
||||
|
@@ -1304,6 +1304,12 @@ msgstr "Подтвердить"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "Отменить синхронизацию"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr "Сайт Joplin"
|
||||
|
||||
|
@@ -1247,6 +1247,12 @@ msgstr "确认"
|
||||
msgid "Cancel synchronisation"
|
||||
msgstr "取消同步"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type new tags or select from list"
|
||||
msgstr ""
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -8,6 +8,7 @@ const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const NoteResource = require('lib/models/NoteResource.js');
|
||||
const ResourceService = require('lib/services/ResourceService.js');
|
||||
const fs = require('fs-extra');
|
||||
const ArrayUtils = require('lib/ArrayUtils');
|
||||
@@ -67,6 +68,32 @@ describe('services_ResourceService', function() {
|
||||
|
||||
expect(!!(await Resource.load(resource1.id))).toBe(false);
|
||||
expect(await shim.fsDriver().exists(resourcePath)).toBe(false);
|
||||
expect(!(await NoteResource.all()).length).toBe(true);
|
||||
}));
|
||||
|
||||
it('should not delete resource if still associated with at least one note', asyncTest(async () => {
|
||||
const service = new ResourceService();
|
||||
|
||||
let folder1 = await Folder.save({ title: "folder1" });
|
||||
let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
||||
let note2 = await Note.save({ title: 'ma deuxième note', parent_id: folder1.id });
|
||||
note1 = await shim.attachFileToNote(note1, __dirname + '/../tests/support/photo.jpg');
|
||||
let resource1 = (await Resource.all())[0];
|
||||
const resourcePath = Resource.fullPath(resource1);
|
||||
|
||||
await service.indexNoteResources();
|
||||
|
||||
await Note.delete(note1.id);
|
||||
|
||||
await service.indexNoteResources();
|
||||
|
||||
await Note.save({ id: note2.id, body: Resource.markdownTag(resource1) });
|
||||
|
||||
await service.indexNoteResources();
|
||||
|
||||
await service.deleteOrphanResources(0);
|
||||
|
||||
expect(!!(await Resource.load(resource1.id))).toBe(true);
|
||||
}));
|
||||
|
||||
});
|
@@ -368,11 +368,11 @@ class Application extends BaseApplication {
|
||||
}, {
|
||||
label: _('Search in all the notes'),
|
||||
screens: ['Main'],
|
||||
accelerator: 'F6',
|
||||
accelerator: 'CommandOrControl+F',
|
||||
click: () => {
|
||||
this.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'search',
|
||||
name: 'focus_search',
|
||||
});
|
||||
},
|
||||
}],
|
||||
|
@@ -6,6 +6,63 @@ const { _ } = require('lib/locale.js');
|
||||
|
||||
class HeaderComponent extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
searchQuery: '',
|
||||
};
|
||||
|
||||
this.scheduleSearchChangeEventIid_ = null;
|
||||
this.searchOnQuery_ = null;
|
||||
this.searchElement_ = null;
|
||||
|
||||
const triggerOnQuery = (query) => {
|
||||
clearTimeout(this.scheduleSearchChangeEventIid_);
|
||||
if (this.searchOnQuery_) this.searchOnQuery_(query);
|
||||
this.scheduleSearchChangeEventIid_ = null;
|
||||
}
|
||||
|
||||
this.search_onChange = (event) => {
|
||||
this.setState({ searchQuery: event.target.value });
|
||||
|
||||
if (this.scheduleSearchChangeEventIid_) clearTimeout(this.scheduleSearchChangeEventIid_);
|
||||
|
||||
this.scheduleSearchChangeEventIid_ = setTimeout(() => {
|
||||
triggerOnQuery(this.state.searchQuery);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
this.search_onClear = (event) => {
|
||||
this.setState({ searchQuery: '' });
|
||||
triggerOnQuery('');
|
||||
}
|
||||
}
|
||||
|
||||
async componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.windowCommand) {
|
||||
this.doCommand(nextProps.windowCommand);
|
||||
}
|
||||
}
|
||||
|
||||
async doCommand(command) {
|
||||
if (!command) return;
|
||||
|
||||
let commandProcessed = true;
|
||||
|
||||
if (command.name === 'focus_search' && this.searchElement_) {
|
||||
this.searchElement_.focus();
|
||||
} else {
|
||||
commandProcessed = false;
|
||||
}
|
||||
|
||||
if (commandProcessed) {
|
||||
this.props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
back_click() {
|
||||
this.props.dispatch({ type: 'NAV_BACK' });
|
||||
}
|
||||
@@ -40,6 +97,59 @@ class HeaderComponent extends React.Component {
|
||||
</a>
|
||||
}
|
||||
|
||||
makeSearch(key, style, options, state) {
|
||||
const inputStyle = {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
color: style.color,
|
||||
fontSize: style.fontSize,
|
||||
fontFamily: style.fontFamily,
|
||||
};
|
||||
|
||||
const searchButton = {
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
textDecoration: 'none',
|
||||
};
|
||||
|
||||
const iconStyle = {
|
||||
display: 'flex',
|
||||
fontSize: Math.round(style.fontSize) * 1.2,
|
||||
color: style.color,
|
||||
};
|
||||
|
||||
const containerStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const iconName = state.searchQuery ? 'fa-times' : 'fa-search';
|
||||
const icon = <i style={iconStyle} className={"fa " + iconName}></i>
|
||||
if (options.onQuery) this.searchOnQuery_ = options.onQuery;
|
||||
|
||||
return (
|
||||
<div key={key} style={containerStyle}>
|
||||
<input
|
||||
type="text"
|
||||
style={inputStyle}
|
||||
placeholder={options.title}
|
||||
value={state.searchQuery}
|
||||
onChange={this.search_onChange}
|
||||
ref={elem => this.searchElement_ = elem}
|
||||
/>
|
||||
<a
|
||||
href="#"
|
||||
style={searchButton}
|
||||
onClick={this.search_onClear}
|
||||
>{icon}</a>
|
||||
</div>);
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = Object.assign({}, this.props.style);
|
||||
const theme = themeStyle(this.props.theme);
|
||||
@@ -50,9 +160,9 @@ class HeaderComponent extends React.Component {
|
||||
style.borderBottom = '1px solid ' + theme.dividerColor;
|
||||
style.boxSizing = 'border-box';
|
||||
|
||||
const buttons = [];
|
||||
const items = [];
|
||||
|
||||
const buttonStyle = {
|
||||
const itemStyle = {
|
||||
height: theme.headerHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -67,19 +177,24 @@ class HeaderComponent extends React.Component {
|
||||
};
|
||||
|
||||
if (showBackButton) {
|
||||
buttons.push(this.makeButton('back', buttonStyle, { title: _('Back'), onClick: () => this.back_click(), iconName: 'fa-chevron-left ' }));
|
||||
items.push(this.makeButton('back', itemStyle, { title: _('Back'), onClick: () => this.back_click(), iconName: 'fa-chevron-left ' }));
|
||||
}
|
||||
|
||||
if (this.props.buttons) {
|
||||
for (let i = 0; i < this.props.buttons.length; i++) {
|
||||
const o = this.props.buttons[i];
|
||||
buttons.push(this.makeButton('btn_' + i + '_' + o.title, buttonStyle, o));
|
||||
if (this.props.items) {
|
||||
for (let i = 0; i < this.props.items.length; i++) {
|
||||
const item = this.props.items[i];
|
||||
|
||||
if (item.type === 'search') {
|
||||
items.push(this.makeSearch('item_' + i + '_search', itemStyle, item, this.state));
|
||||
} else {
|
||||
items.push(this.makeButton('item_' + i + '_' + item.title, itemStyle, item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="header" style={style}>
|
||||
{ buttons }
|
||||
{ items }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +202,10 @@ class HeaderComponent extends React.Component {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return { theme: state.settings.theme };
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
windowCommand: state.windowCommand,
|
||||
};
|
||||
};
|
||||
|
||||
const Header = connect(mapStateToProps)(HeaderComponent);
|
||||
|
@@ -140,33 +140,27 @@ class MainScreenComponent extends React.Component {
|
||||
},
|
||||
});
|
||||
} else if (command.name === 'search') {
|
||||
this.setState({
|
||||
promptOptions: {
|
||||
label: _('Search:'),
|
||||
onClose: async (answer) => {
|
||||
if (answer !== null) {
|
||||
const searchId = uuid.create();
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'SEARCH_ADD',
|
||||
search: {
|
||||
id: searchId,
|
||||
title: answer,
|
||||
query_pattern: answer,
|
||||
query_folder_id: null,
|
||||
type_: BaseModel.TYPE_SEARCH,
|
||||
},
|
||||
});
|
||||
if (!this.searchId_) this.searchId_ = uuid.create();
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'SEARCH_SELECT',
|
||||
id: searchId,
|
||||
});
|
||||
}
|
||||
this.setState({ promptOptions: null });
|
||||
}
|
||||
this.props.dispatch({
|
||||
type: 'SEARCH_UPDATE',
|
||||
search: {
|
||||
id: this.searchId_,
|
||||
title: command.query,
|
||||
query_pattern: command.query,
|
||||
query_folder_id: null,
|
||||
type_: BaseModel.TYPE_SEARCH,
|
||||
},
|
||||
});
|
||||
|
||||
if (command.query) {
|
||||
this.props.dispatch({
|
||||
type: 'SEARCH_SELECT',
|
||||
id: this.searchId_,
|
||||
});
|
||||
}
|
||||
|
||||
} else if (command.name === 'toggleVisiblePanes') {
|
||||
this.toggleVisiblePanes();
|
||||
} else if (command.name === 'showModalMessage') {
|
||||
@@ -278,7 +272,7 @@ class MainScreenComponent extends React.Component {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: theme.backgroundColorTransparent,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
width: width - 20,
|
||||
height: height - 20,
|
||||
padding: 10,
|
||||
@@ -298,41 +292,42 @@ class MainScreenComponent extends React.Component {
|
||||
const selectedFolderId = this.props.selectedFolderId;
|
||||
const onConflictFolder = this.props.selectedFolderId === Folder.conflictFolderId();
|
||||
|
||||
const headerButtons = [];
|
||||
const headerItems = [];
|
||||
|
||||
headerButtons.push({
|
||||
headerItems.push({
|
||||
title: _('New note'),
|
||||
iconName: 'fa-file-o',
|
||||
enabled: !!folders.length && !onConflictFolder,
|
||||
onClick: () => { this.doCommand({ name: 'newNote' }) },
|
||||
});
|
||||
|
||||
headerButtons.push({
|
||||
headerItems.push({
|
||||
title: _('New to-do'),
|
||||
iconName: 'fa-check-square-o',
|
||||
enabled: !!folders.length && !onConflictFolder,
|
||||
onClick: () => { this.doCommand({ name: 'newTodo' }) },
|
||||
});
|
||||
|
||||
headerButtons.push({
|
||||
headerItems.push({
|
||||
title: _('New notebook'),
|
||||
iconName: 'fa-folder-o',
|
||||
onClick: () => { this.doCommand({ name: 'newNotebook' }) },
|
||||
});
|
||||
|
||||
headerButtons.push({
|
||||
title: _('Search'),
|
||||
iconName: 'fa-search',
|
||||
onClick: () => { this.doCommand({ name: 'search' }) },
|
||||
});
|
||||
|
||||
headerButtons.push({
|
||||
headerItems.push({
|
||||
title: _('Layout'),
|
||||
iconName: 'fa-columns',
|
||||
enabled: !!notes.length,
|
||||
onClick: () => { this.doCommand({ name: 'toggleVisiblePanes' }) },
|
||||
});
|
||||
|
||||
headerItems.push({
|
||||
title: _('Search...'),
|
||||
iconName: 'fa-search',
|
||||
onQuery: (query) => { this.doCommand({ name: 'search', query: query }) },
|
||||
type: 'search',
|
||||
});
|
||||
|
||||
if (!this.promptOnClose_) {
|
||||
this.promptOnClose_ = (answer, buttonType) => {
|
||||
return this.state.promptOptions.onClose(answer, buttonType);
|
||||
@@ -389,7 +384,7 @@ class MainScreenComponent extends React.Component {
|
||||
visible={!!this.state.promptOptions}
|
||||
buttons={promptOptions && ('buttons' in promptOptions) ? promptOptions.buttons : null}
|
||||
inputType={promptOptions && ('inputType' in promptOptions) ? promptOptions.inputType : null} />
|
||||
<Header style={styles.header} showBackButton={false} buttons={headerButtons} />
|
||||
<Header style={styles.header} showBackButton={false} items={headerItems} />
|
||||
{messageComp}
|
||||
<SideBar style={styles.sideBar} />
|
||||
<NoteList style={styles.noteList} />
|
||||
|
@@ -11,6 +11,8 @@ const MenuItem = bridge().MenuItem;
|
||||
const eventManager = require('../eventManager');
|
||||
const InteropService = require('lib/services/InteropService');
|
||||
const InteropServiceHelper = require('../InteropServiceHelper.js');
|
||||
const Search = require('lib/models/Search');
|
||||
const Mark = require('mark.js/dist/mark.min.js');
|
||||
|
||||
class NoteListComponent extends React.Component {
|
||||
|
||||
@@ -164,6 +166,12 @@ class NoteListComponent extends React.Component {
|
||||
|
||||
const hPadding = 10;
|
||||
|
||||
let highlightedWords = [];
|
||||
if (this.props.notesParentType === 'Search') {
|
||||
const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
|
||||
highlightedWords = search ? Search.keywords(search.query_pattern) : [];
|
||||
}
|
||||
|
||||
let style = Object.assign({ width: width }, this.style().listItem);
|
||||
|
||||
if (this.props.selectedNoteIds.indexOf(item.id) >= 0) {
|
||||
@@ -182,8 +190,30 @@ class NoteListComponent extends React.Component {
|
||||
listItemTitleStyle.paddingLeft = !checkbox ? hPadding : 4;
|
||||
if (item.is_todo && !!item.todo_completed) listItemTitleStyle = Object.assign(listItemTitleStyle, this.style().listItemTitleCompleted);
|
||||
|
||||
let displayTitle = Note.displayTitle(item);
|
||||
let titleComp = null;
|
||||
|
||||
if (highlightedWords.length) {
|
||||
const titleElement = document.createElement('span');
|
||||
titleElement.textContent = displayTitle;
|
||||
const mark = new Mark(titleElement, {
|
||||
exclude: ['img'],
|
||||
acrossElements: true,
|
||||
});
|
||||
mark.mark(highlightedWords);
|
||||
|
||||
// Note: in this case it is safe to use dangerouslySetInnerHTML because titleElement
|
||||
// is a span tag that we created and that contains data that's been inserted as plain text
|
||||
// with `textContent` so it cannot contain any XSS attacks. We use this feature because
|
||||
// mark.js can only deal with DOM elements.
|
||||
// https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
|
||||
titleComp = <span dangerouslySetInnerHTML={{ __html: titleElement.outerHTML }}></span>
|
||||
} else {
|
||||
titleComp = <span>{displayTitle}</span>
|
||||
}
|
||||
|
||||
// Need to include "todo_completed" in key so that checkbox is updated when
|
||||
// item is changed via sync.
|
||||
// item is changed via sync.
|
||||
return <div key={item.id + '_' + item.todo_completed} style={style}>
|
||||
{checkbox}
|
||||
<a
|
||||
@@ -196,7 +226,7 @@ class NoteListComponent extends React.Component {
|
||||
onDragStart={(event) => onDragStart(event) }
|
||||
data-id={item.id}
|
||||
>
|
||||
{Note.displayTitle(item)}
|
||||
{titleComp}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -239,7 +269,9 @@ const mapStateToProps = (state) => {
|
||||
folders: state.folders,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
theme: state.settings.theme,
|
||||
// uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
notesParentType: state.notesParentType,
|
||||
searches: state.searches,
|
||||
selectedSearchId: state.selectedSearchId,
|
||||
};
|
||||
};
|
||||
|
||||
|
47
ElectronClient/app/gui/NoteStatusBar.jsx
Normal file
47
ElectronClient/app/gui/NoteStatusBar.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
const React = require('react');
|
||||
const { connect } = require('react-redux');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { themeStyle } = require('../theme.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
|
||||
class NoteStatusBarComponent extends React.Component {
|
||||
|
||||
style() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
const itemHeight = 34;
|
||||
|
||||
let style = {
|
||||
root: Object.assign({}, theme.textStyle, {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
color: theme.colorFaded,
|
||||
}),
|
||||
};
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
const style = this.props.style;
|
||||
const note = this.props.note;
|
||||
|
||||
return (
|
||||
<div style={this.style().root}>{time.formatMsToLocal(note.user_updated_time)}</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
// notes: state.notes,
|
||||
// folders: state.folders,
|
||||
// selectedNoteIds: state.selectedNoteIds,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
};
|
||||
|
||||
const NoteStatusBar = connect(mapStateToProps)(NoteStatusBarComponent);
|
||||
|
||||
module.exports = { NoteStatusBar };
|
@@ -1,5 +1,7 @@
|
||||
const React = require('react');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Search = require('lib/models/Search.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const { IconButton } = require('./IconButton.min.js');
|
||||
@@ -195,7 +197,9 @@ class NoteTextComponent extends React.Component {
|
||||
this.editorSetScrollTop(1);
|
||||
this.restoreScrollTop_ = 0;
|
||||
|
||||
if (note) {
|
||||
// If a search is in progress we don't focus any field otherwise it will
|
||||
// take the focus out of the search box.
|
||||
if (note && this.props.notesParentType !== 'Search') {
|
||||
const focusSettingName = !!note.is_todo ? 'newTodoFocus' : 'newNoteFocus';
|
||||
|
||||
if (Setting.value(focusSettingName) === 'title') {
|
||||
@@ -366,7 +370,7 @@ class NoteTextComponent extends React.Component {
|
||||
webviewReady: true,
|
||||
});
|
||||
|
||||
// if (Setting.value('env') === 'dev') this.webview_.openDevTools();
|
||||
if (Setting.value('env') === 'dev') this.webview_.openDevTools();
|
||||
}
|
||||
|
||||
webview_ref(element) {
|
||||
@@ -393,6 +397,18 @@ class NoteTextComponent extends React.Component {
|
||||
|
||||
if (this.editor_) {
|
||||
this.editor_.editor.renderer.on('afterRender', this.onAfterEditorRender_);
|
||||
|
||||
const cancelledKeys = ['Ctrl+F', 'Ctrl+T', 'Ctrl+P', 'Ctrl+Q', 'Ctrl+L', 'Ctrl+,'];
|
||||
for (let i = 0; i < cancelledKeys.length; i++) {
|
||||
const k = cancelledKeys[i];
|
||||
this.editor_.editor.commands.bindKey(k, () => {
|
||||
// HACK: Ace doesn't seem to provide a way to override its shortcuts, but throwing
|
||||
// an exception from this undocumented function seems to cancel it without any
|
||||
// side effect.
|
||||
// https://stackoverflow.com/questions/36075846
|
||||
throw new Error('HACK: Overriding Ace Editor shortcut: ' + k);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +582,7 @@ class NoteTextComponent extends React.Component {
|
||||
marginBottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const titleEditorStyle = {
|
||||
@@ -642,6 +659,10 @@ class NoteTextComponent extends React.Component {
|
||||
|
||||
const html = this.mdToHtml().render(bodyToRender, theme, mdOptions);
|
||||
this.webview_.send('setHtml', html);
|
||||
|
||||
const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
|
||||
const keywords = search ? Search.keywords(search.query_pattern) : [];
|
||||
this.webview_.send('setMarkers', keywords);
|
||||
}
|
||||
|
||||
const toolbarItems = [];
|
||||
@@ -685,6 +706,8 @@ class NoteTextComponent extends React.Component {
|
||||
display: 'flex',
|
||||
}} iconName="fa-caret-down" theme={this.props.theme} onClick={() => { this.itemContextMenu() }} />
|
||||
|
||||
const titleBarDate = <span style={Object.assign({}, theme.textStyle, {color: theme.colorFaded})}>{time.formatMsToLocal(note.user_updated_time)}</span>
|
||||
|
||||
const viewer = <webview
|
||||
style={viewerStyle}
|
||||
nodeintegration="1"
|
||||
@@ -692,6 +715,24 @@ class NoteTextComponent extends React.Component {
|
||||
ref={(elem) => { this.webview_ref(elem); } }
|
||||
/>
|
||||
|
||||
// const markers = [{
|
||||
// startRow: 2,
|
||||
// startCol: 3,
|
||||
// endRow: 2,
|
||||
// endCol: 6,
|
||||
// type: 'text',
|
||||
// className: 'test-marker'
|
||||
// }];
|
||||
|
||||
// markers={markers}
|
||||
// editorProps={{$useWorker: false}}
|
||||
|
||||
// #note-editor .test-marker {
|
||||
// background-color: red;
|
||||
// color: yellow;
|
||||
// position: absolute;
|
||||
// }
|
||||
|
||||
const editorRootStyle = Object.assign({}, editorStyle);
|
||||
delete editorRootStyle.width;
|
||||
delete editorRootStyle.height;
|
||||
@@ -718,14 +759,15 @@ class NoteTextComponent extends React.Component {
|
||||
editorProps={{$blockScrolling: true}}
|
||||
|
||||
// This is buggy (gets outside the container)
|
||||
highlightActiveLine={false}
|
||||
highlightActiveLine={false}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div style={rootStyle}>
|
||||
<div style={titleBarStyle}>
|
||||
{ titleEditor }
|
||||
{ titleBarMenuButton }
|
||||
{ titleBarDate }
|
||||
{ false ? titleBarMenuButton : null }
|
||||
</div>
|
||||
{ toolbar }
|
||||
{ editor }
|
||||
@@ -747,6 +789,9 @@ const mapStateToProps = (state) => {
|
||||
syncStarted: state.syncStarted,
|
||||
newNote: state.newNote,
|
||||
windowCommand: state.windowCommand,
|
||||
notesParentType: state.notesParentType,
|
||||
searches: state.searches,
|
||||
selectedSearchId: state.selectedSearchId,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -345,17 +345,17 @@ class SideBarComponent extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.searches.length) {
|
||||
items.push(this.makeHeader("searchHeader", _("Searches"), "fa-search"));
|
||||
// if (this.props.searches.length) {
|
||||
// items.push(this.makeHeader("searchHeader", _("Searches"), "fa-search"));
|
||||
|
||||
const searchItems = shared.renderSearches(this.props, this.searchItem.bind(this));
|
||||
// const searchItems = shared.renderSearches(this.props, this.searchItem.bind(this));
|
||||
|
||||
items.push(
|
||||
<div className="searches" key="search_items">
|
||||
{searchItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// items.push(
|
||||
// <div className="searches" key="search_items">
|
||||
// {searchItems}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
let lines = Synchronizer.reportToLines(this.props.syncReport);
|
||||
const syncReportText = [];
|
||||
|
@@ -1,6 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<style>
|
||||
body {
|
||||
overflow: hidden;
|
||||
@@ -13,12 +15,18 @@
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: #CF3F00;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.katex { font-size: 1.3em; } /* This controls the global Katex font size*/
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body id="body">
|
||||
<div id="hlScriptContainer"></div>
|
||||
<div id="markScriptContainer"></div>
|
||||
<div id="content" ondragstart="return false;" ondrop="return false;"></div>
|
||||
|
||||
<script>
|
||||
@@ -38,7 +46,7 @@
|
||||
const script = document.createElement('script');
|
||||
script.onload = function () {
|
||||
hljsLoaded = true;
|
||||
applyHljs();
|
||||
applyHljs();
|
||||
};
|
||||
script.src = 'highlight/highlight.pack.js';
|
||||
document.getElementById('hlScriptContainer').appendChild(script);
|
||||
@@ -160,6 +168,36 @@
|
||||
setPercentScroll(percent);
|
||||
});
|
||||
|
||||
let mark_ = null;
|
||||
function setMarkers(keywords) {
|
||||
if (!mark_) {
|
||||
mark_ = new Mark(document.getElementById('content'), {
|
||||
exclude: ['img'],
|
||||
acrossElements: true,
|
||||
});
|
||||
}
|
||||
|
||||
mark_.mark(keywords);
|
||||
}
|
||||
|
||||
let markLoaded_ = false;
|
||||
ipcRenderer.on('setMarkers', (event, keywords) => {
|
||||
if (!keywords.length && !markLoaded_) return;
|
||||
|
||||
if (!markLoaded_) {
|
||||
const script = document.createElement('script');
|
||||
script.onload = function() {
|
||||
setMarkers(keywords);
|
||||
};
|
||||
|
||||
script.src = '../../node_modules/mark.js/dist/mark.min.js';
|
||||
document.getElementById('markScriptContainer').appendChild(script);
|
||||
markLoaded_ = true;
|
||||
} else {
|
||||
setMarkers(keywords);
|
||||
}
|
||||
});
|
||||
|
||||
function maxScrollTop() {
|
||||
return Math.max(0, contentElement.scrollHeight - contentElement.clientHeight);
|
||||
}
|
||||
|
@@ -17,6 +17,10 @@
|
||||
.smalltalk .page {
|
||||
max-width: 30em;
|
||||
}
|
||||
mark {
|
||||
background: #CF3F00;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
1
ElectronClient/app/locales/da_DK.json
Normal file
1
ElectronClient/app/locales/da_DK.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
var locales = {};
|
||||
locales['en_GB'] = require('./en_GB.json');
|
||||
locales['da_DK'] = require('./da_DK.json');
|
||||
locales['de_DE'] = require('./de_DE.json');
|
||||
locales['es_ES'] = require('./es_ES.json');
|
||||
locales['eu'] = require('./eu.json');
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
7
ElectronClient/app/package-lock.json
generated
7
ElectronClient/app/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Joplin",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.79",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -3767,6 +3767,11 @@
|
||||
"integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=",
|
||||
"dev": true
|
||||
},
|
||||
"mark.js": {
|
||||
"version": "8.11.1",
|
||||
"resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz",
|
||||
"integrity": "sha1-GA8fnr74sOY45BZq1S24eb6y/8U="
|
||||
},
|
||||
"markdown-it": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.0.tgz",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Joplin",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.79",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
@@ -71,6 +71,7 @@
|
||||
"katex": "^0.9.0-beta1",
|
||||
"levenshtein": "^1.0.5",
|
||||
"lodash": "^4.17.4",
|
||||
"mark.js": "^8.11.1",
|
||||
"markdown-it": "^8.4.0",
|
||||
"markdown-it-katex": "^2.0.3",
|
||||
"md5": "^2.2.1",
|
||||
|
@@ -36,12 +36,14 @@ table td, table th {
|
||||
|
||||
.side-bar .list-item:hover,
|
||||
.side-bar .synchronize-button:hover {
|
||||
background-color: #453E53;
|
||||
/*background-color: #453E53;*/
|
||||
background-color: #01427B;
|
||||
}
|
||||
|
||||
.side-bar .list-item:active,
|
||||
.side-bar .synchronize-button:active {
|
||||
background-color: #564B6C;
|
||||
/*background-color: #564B6C;*/
|
||||
background-color: #0465BB;
|
||||
}
|
||||
|
||||
.editor-toolbar .button:not(.disabled):hover,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
|
||||
const globalStyle = {
|
||||
fontSize: 12 * Setting.value('style.zoom')/100,
|
||||
fontSize: 12 * Setting.value('style.zoom') / 100,
|
||||
fontFamily: 'sans-serif',
|
||||
margin: 15, // No text and no interactive component should be within this margin
|
||||
itemMarginTop: 10,
|
||||
@@ -21,9 +21,11 @@ const globalStyle = {
|
||||
buttonMinHeight: 30,
|
||||
textAreaLineHeight: 17,
|
||||
|
||||
backgroundColor2: "#2B2634",
|
||||
//backgroundColor2: "#2B2634",
|
||||
backgroundColor2: "#162B3D",
|
||||
color2: "#ffffff",
|
||||
selectedColor2: "#5A4D70",
|
||||
//selectedColor2: "#5A4D70",
|
||||
selectedColor2: "#0269C2",
|
||||
colorError2: "#ff6c6c",
|
||||
|
||||
warningBackgroundColor: "#FFD08D",
|
||||
|
30
README.md
30
README.md
@@ -1,10 +1,12 @@
|
||||
# Joplin
|
||||
|
||||
[](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=E8JMYD2LQ8MMA&lc=GB&item_name=Joplin+Development¤cy_code=EUR&bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted) [](https://en.cryptobadges.io/donate/1AnbeRd5NZT1ssG93jXzaDoHwzgjQAHX3R) [](https://travis-ci.org/laurent22/joplin) [](https://ci.appveyor.com/project/laurent22/joplin)
|
||||
|
||||
Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in [Markdown format](#markdown).
|
||||
|
||||
Notes exported from Evernote via .enex files [can be imported](#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
|
||||
|
||||
The notes can be [synchronised](#synchronisation) with various targets including [Nextcloud](https://nextcloud.com/), the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.
|
||||
The notes can be [synchronised](#synchronisation) with various cloud services including [Nextcloud](https://nextcloud.com/), the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.
|
||||
|
||||
The UI of the terminal client is built on top of the great [terminal-kit](https://github.com/cronvel/terminal-kit) library, the desktop client using [Electron](https://electronjs.org/), and the Android client front end is done using [React Native](https://facebook.github.io/react-native/).
|
||||
|
||||
@@ -18,15 +20,15 @@ Three types of applications are available: for the **desktop** (Windows, macOS a
|
||||
|
||||
Operating System | Download
|
||||
-----------------|--------
|
||||
Windows | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.70/Joplin-Setup-1.0.70.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.70/Joplin-1.0.70.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.70/Joplin-1.0.70-x86_64.AppImage'><img alt='Get it on Linux' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a>
|
||||
Windows | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.78/Joplin-Setup-1.0.78.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.78/Joplin-1.0.78.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.78/Joplin-1.0.78-x86_64.AppImage'><img alt='Get it on Linux' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a>
|
||||
|
||||
## Mobile applications
|
||||
|
||||
Operating System | Download | Alt. Download
|
||||
-----------------|----------|----------------
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.107/joplin-v1.0.107.apk)
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.112/joplin-v1.0.112.apk)
|
||||
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeIOS.png'/></a> | -
|
||||
|
||||
## Terminal application
|
||||
@@ -108,7 +110,9 @@ Currently, synchronisation is possible with Nextcloud and OneDrive (by default)
|
||||
|
||||
## Nextcloud synchronisation
|
||||
|
||||
On the **desktop application** or **mobile application**, go to the config screen and select Nextcloud as the synchronisation target. Then input [the WebDAV URL](https://docs.nextcloud.com/server/9/user_manual/files/access_webdav.html), this is normally `https://example.com/nextcloud/remote.php/dav/files/USERNAME/Joplin` (**make sure to create the "Joplin" directory in Nextcloud** and to replace USERNAME by your Nextcloud username), and set the username and password. If it does not work, please [see this explanation](https://github.com/laurent22/joplin/issues/61#issuecomment-373282608) for more details.
|
||||
<img src="https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/nextcloud-logo-background.png" width="100" align="left"> <a href="https://nextcloud.com/">Nextcloud</a> is a self-hosted, private cloud solution. It can store documents, images and videos but also calendars, passwords and countless other things and can sync them to your laptop or phone. As you can host your own Nextcloud server, you own both the data on your device and infrastructure used for synchronisation. As such it is a good fit for Joplin. The platform is also well supported and with a strong community, so it is likely to be around for a while - since it's open source anyway, it is not a service that can be closed, it can exist on a server for as long as one chooses.
|
||||
|
||||
On the **desktop application** or **mobile application**, go to the config screen and select Nextcloud as the synchronisation target. Then input the WebDAV URL (to get it, click on Settings in the bottom left corner of the page, in Nextcloud), this is normally `https://example.com/nextcloud/remote.php/webdav/Joplin` (**make sure to create the "Joplin" directory in Nextcloud**), and set the username and password. If it does not work, please [see this explanation](https://github.com/laurent22/joplin/issues/61#issuecomment-373282608) for more details.
|
||||
|
||||
On the **terminal application**, you will need to set the `sync.target` config variable and all the `sync.5.path`, `sync.5.username` and `sync.5.password` config variables to, respectively the Nextcloud WebDAV URL, your username and your password. This can be done from the command line mode using:
|
||||
|
||||
@@ -123,10 +127,11 @@ If synchronisation does not work, please consult the logs in the app profile dir
|
||||
|
||||
Select the "WebDAV" synchronisation target and follow the same instructions as for Nextcloud above.
|
||||
|
||||
WebDAV-compatible services that are known to work with Joplin:
|
||||
WebDAV-compatible services that are known to work with Joplin.
|
||||
|
||||
- [Box.com](https://www.box.com/)
|
||||
- [DriveHQ](https://www.drivehq.com)
|
||||
- [HiDrive](https://www.strato.fr/stockage-en-ligne/) from Strato. [Setup help](https://github.com/laurent22/joplin/issues/309)
|
||||
- [OwnCloud](https://owncloud.org/)
|
||||
- [Seafile](https://www.seafile.com/)
|
||||
- [Stack](https://www.transip.nl/stack/)
|
||||
@@ -230,13 +235,14 @@ Current translations:
|
||||
---|---|---|---|---
|
||||
 | Basque | [eu](https://github.com/laurent22/joplin/blob/master/CliClient/locales/eu.po) | juan.abasolo@ehu.eus | 79%
|
||||
 | Croatian | [hr_HR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po) | Hrvoje Mandić <trbuhom@net.hr> | 64%
|
||||
 | Deutsch | [de_DE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po) | Tobias Grasse <mail@tobias-grasse.net> | 99%
|
||||
 | Dansk | [da_DK](https://github.com/laurent22/joplin/blob/master/CliClient/locales/da_DK.po) | | 99%
|
||||
 | Deutsch | [de_DE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po) | Tobias Grasse <mail@tobias-grasse.net> | 98%
|
||||
 | English | [en_GB](https://github.com/laurent22/joplin/blob/master/CliClient/locales/en_GB.po) | | 100%
|
||||
 | Español | [es_ES](https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po) | Fernando Martín <f@mrtn.es> | 99%
|
||||
 | Français | [fr_FR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po) | Laurent Cozic | 99%
|
||||
 | Français | [fr_FR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po) | Laurent Cozic | 100%
|
||||
 | Italiano | [it_IT](https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po) | | 66%
|
||||
 | Nederlands | [nl_BE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po) | | 80%
|
||||
 | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po) | Renato Nunes Bastos <rnbastos@gmail.com> | 98%
|
||||
 | Nederlands | [nl_BE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po) | | 79%
|
||||
 | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po) | Renato Nunes Bastos <rnbastos@gmail.com> | 97%
|
||||
 | Русский | [ru_RU](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po) | Artyom Karlov <artyom.karlov@gmail.com> | 99%
|
||||
 | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/master/CliClient/locales/zh_CN.po) | RCJacH <RCJacH@outlook.com> | 66%
|
||||
 | 日本語 | [ja_JP](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ja_JP.po) | | 64%
|
||||
@@ -245,7 +251,7 @@ Current translations:
|
||||
# Known bugs
|
||||
|
||||
- Non-alphabetical characters such as Chinese or Arabic might create glitches in the terminal on Windows. This is a limitation of the current Windows console.
|
||||
- While the mobile can sync and load tags, it is not currently possible to create new ones. The desktop and terminal apps can create, delete and edit tags.
|
||||
- It is only possible to upload files of up to 4MB to OneDrive due to a limitation of [the API](https://docs.microsoft.com/en-gb/onedrive/developer/rest-api/api/driveitem_put_content) being currently used. There is currently no plan to support OneDrive "large file" API.
|
||||
|
||||
# License
|
||||
|
||||
|
@@ -90,8 +90,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 22
|
||||
versionCode 2097285
|
||||
versionName "1.0.107"
|
||||
versionCode 2097290
|
||||
versionName "1.0.112"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86"
|
||||
}
|
||||
|
@@ -260,7 +260,7 @@ class BaseApplication {
|
||||
const newState = store.getState();
|
||||
let refreshNotes = false;
|
||||
|
||||
if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE') {
|
||||
if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE' || (action.type === 'SEARCH_UPDATE' && newState.notesParentType === 'Folder')) {
|
||||
Setting.setValue('activeFolderId', newState.selectedFolderId);
|
||||
this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null;
|
||||
refreshNotes = true;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -3,7 +3,9 @@ const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const ModelCache = require('lib/ModelCache');
|
||||
const ObjectUtils = require('lib/ObjectUtils');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { _ } = require('lib/locale');
|
||||
const md5 = require('md5');
|
||||
const MdToHtml_Katex = require('lib/MdToHtml_Katex');
|
||||
|
||||
@@ -54,11 +56,11 @@ class MdToHtml {
|
||||
return output.join(' ');
|
||||
}
|
||||
|
||||
getAttr_(attrs, name) {
|
||||
getAttr_(attrs, name, defaultValue = null) {
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
if (attrs[i][0] === name) return attrs[i].length > 1 ? attrs[i][1] : null;
|
||||
}
|
||||
return null;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
setAttr_(attrs, name, value) {
|
||||
@@ -182,11 +184,23 @@ class MdToHtml {
|
||||
return null;
|
||||
}
|
||||
|
||||
urldecode_(str) {
|
||||
try {
|
||||
return decodeURIComponent((str+'').replace(/\+/g, '%20'));
|
||||
} catch (error) {
|
||||
// decodeURIComponent can throw if the string contains non-encoded data (for example "100%")
|
||||
// so in this case just return the non encoded string.
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
renderTokens_(markdownIt, tokens, options) {
|
||||
let output = [];
|
||||
let previousToken = null;
|
||||
let anchorAttrs = [];
|
||||
let extraCssBlocks = {};
|
||||
let anchorHrefs = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
let t = tokens[i];
|
||||
@@ -202,6 +216,7 @@ class MdToHtml {
|
||||
const codeBlockLanguage = t && t.info ? t.info : null;
|
||||
let rendererPlugin = null;
|
||||
let rendererPluginOptions = { tagType: 'inline' };
|
||||
let linkHref = null;
|
||||
|
||||
if (isCodeBlock) rendererPlugin = this.rendererPlugin_(codeBlockLanguage);
|
||||
|
||||
@@ -233,6 +248,7 @@ class MdToHtml {
|
||||
if (openTag) {
|
||||
if (openTag === 'a') {
|
||||
anchorAttrs.push(attrs);
|
||||
anchorHrefs.push(this.getAttr_(attrs, 'href'));
|
||||
output.push(this.renderOpenLink_(attrs, options));
|
||||
} else {
|
||||
const attrsHtml = this.renderAttrs_(attrs);
|
||||
@@ -317,7 +333,28 @@ class MdToHtml {
|
||||
|
||||
if (closeTag) {
|
||||
if (closeTag === 'a') {
|
||||
output.push(this.renderCloseLink_(anchorAttrs.pop(), options));
|
||||
const currentAnchorAttrs = anchorAttrs.pop();
|
||||
const previousContent = output.length ? output[output.length - 1].trim() : '';
|
||||
const anchorHref = this.getAttr_(currentAnchorAttrs, 'href', '').trim();
|
||||
|
||||
// Optimisation: If the content of the anchor is the same as the URL, we replace the content
|
||||
// by (Link). This is to shorten the text, which is important especially when the note comes
|
||||
// from imported HTML, which can contain many such links and make the text unreadble. An example
|
||||
// would be a movie review that has multiple links to allow a user to rate the film from 1 to 5 stars.
|
||||
// In the original page, it might be rendered as stars, via CSS, but in the imported note it would look like this:
|
||||
// http://example.com/rate/1 http://example.com/rate/2 http://example.com/rate/3
|
||||
// http://example.com/rate/4 http://example.com/rate/5
|
||||
// which would take a lot of screen space even though it doesn't matter since the user is unlikely
|
||||
// to rate the film from the note. This is actually a nice example, still readable, but there is way
|
||||
// worse that this in notes that come from web-clipped content.
|
||||
// With this change, the links will still be preserved but displayed like
|
||||
// (link) (link) (link) (link) (link)
|
||||
if (this.urldecode_(previousContent) === htmlentities(this.urldecode_(anchorHref))) {
|
||||
output.pop();
|
||||
output.push(_('(Link)'));
|
||||
}
|
||||
|
||||
output.push(this.renderCloseLink_(currentAnchorAttrs, options));
|
||||
} else {
|
||||
output.push('</' + closeTag + '>');
|
||||
}
|
||||
@@ -336,14 +373,16 @@ class MdToHtml {
|
||||
|
||||
// Insert the extra CSS at the top of the HTML
|
||||
|
||||
const temp = ['<style>'];
|
||||
for (let n in extraCssBlocks) {
|
||||
if (!extraCssBlocks.hasOwnProperty(n)) continue;
|
||||
temp.push(extraCssBlocks[n]);
|
||||
}
|
||||
temp.push('</style>');
|
||||
if (!ObjectUtils.isEmpty(extraCssBlocks)) {
|
||||
const temp = ['<style>'];
|
||||
for (let n in extraCssBlocks) {
|
||||
if (!extraCssBlocks.hasOwnProperty(n)) continue;
|
||||
temp.push(extraCssBlocks[n]);
|
||||
}
|
||||
temp.push('</style>');
|
||||
|
||||
output = temp.concat(output);
|
||||
output = temp.concat(output);
|
||||
}
|
||||
|
||||
return output.join('');
|
||||
}
|
||||
|
@@ -53,4 +53,9 @@ ObjectUtils.convertValuesToFunctions = function(o) {
|
||||
return output;
|
||||
}
|
||||
|
||||
ObjectUtils.isEmpty = function(o) {
|
||||
if (!o) return true;
|
||||
return Object.keys(o).length === 0 && o.constructor === Object;
|
||||
}
|
||||
|
||||
module.exports = ObjectUtils;
|
@@ -30,21 +30,21 @@ class ModalDialog extends React.Component {
|
||||
borderColor:theme.dividerColor,
|
||||
margin: 20,
|
||||
padding: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
modalContentWrapper2: {
|
||||
paddingTop: 10,
|
||||
flex:1,
|
||||
},
|
||||
title: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
paddingBottom: 10,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.dividerColor,
|
||||
paddingTop: 10,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,21 +54,22 @@ class ModalDialog extends React.Component {
|
||||
|
||||
render() {
|
||||
const ContentComponent = this.props.ContentComponent;
|
||||
const buttonBarEnabled = this.props.buttonBarEnabled !== false;
|
||||
|
||||
return (
|
||||
<View style={this.styles().modalWrapper}>
|
||||
<Modal transparent={true} visible={true} onRequestClose={() => { }} >
|
||||
<View style={this.styles().modalContentWrapper}>
|
||||
<Text style={this.styles().title}>Title</Text>
|
||||
<View elevation={10} style={this.styles().modalContentWrapper}>
|
||||
<Text style={this.styles().title}>{this.props.title}</Text>
|
||||
<View style={this.styles().modalContentWrapper2}>
|
||||
{ContentComponent}
|
||||
</View>
|
||||
<View style={this.styles().buttonRow}>
|
||||
<View style={{flex:1}}>
|
||||
<Button title={_('OK')} onPress={() => {}}></Button>
|
||||
<Button disabled={!buttonBarEnabled} title={_('OK')} onPress={this.props.onOkPress}></Button>
|
||||
</View>
|
||||
<View style={{flex:1, marginLeft: 5}}>
|
||||
<Button title={_('Cancel')} onPress={() => {}}></Button>
|
||||
<Button disabled={!buttonBarEnabled} title={_('Cancel')} onPress={this.props.onCancelPress}></Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
@@ -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>
|
||||
|
196
ReactNativeClient/lib/components/screens/NoteTagsDialog.js
Normal file
196
ReactNativeClient/lib/components/screens/NoteTagsDialog.js
Normal file
@@ -0,0 +1,196 @@
|
||||
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 { globalStyle, themeStyle } = require('lib/components/global-style.js');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const ModalDialog = require('lib/components/ModalDialog');
|
||||
const naturalCompare = require('string-natural-compare');
|
||||
|
||||
class NoteTagsDialogComponent extends React.Component {
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (this.props.onCloseRequested) this.props.onCloseRequested();
|
||||
}
|
||||
|
||||
this.cancelButton_press = () => {
|
||||
if (this.props.onCloseRequested) this.props.onCloseRequested();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}});
|
||||
|
||||
tagListData.sort((a, b) => {
|
||||
return naturalCompare.caseInsensitive(a.title, b.title);
|
||||
//return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
|
||||
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,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor
|
||||
},
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
const dialogContent = (
|
||||
<View style={{flex:1}}>
|
||||
<View style={this.styles().newTagBox}>
|
||||
<Text>{_('New tags:')}</Text><TextInput value={this.state.newTags} onChangeText={value => { this.setState({ newTags: value }) }} style={{flex:1}}/>
|
||||
</View>
|
||||
<FlatList
|
||||
data={this.state.tagListData}
|
||||
renderItem={this.renderTag}
|
||||
keyExtractor={this.tagKeyExtractor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return <ModalDialog
|
||||
theme={this.props.theme}
|
||||
ContentComponent={dialogContent}
|
||||
title={_('Type new tags or select from list')}
|
||||
onOkPress={this.okButton_press}
|
||||
onCancelPress={this.cancelButton_press}
|
||||
buttonBarEnabled={!this.state.savingTags}
|
||||
/>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const NoteTagsDialog = connect(
|
||||
(state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
tags: state.tags,
|
||||
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
||||
};
|
||||
}
|
||||
)(NoteTagsDialogComponent)
|
||||
|
||||
module.exports = NoteTagsDialog;
|
@@ -15,6 +15,7 @@ const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const { fileExtension, basename, safeFileExtension } = require('lib/path-utils.js');
|
||||
const mimeUtils = require('lib/mime-utils.js').mime;
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const NoteTagsDialog = require('lib/components/screens/NoteTagsDialog');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { Checkbox } = require('lib/components/checkbox.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
@@ -51,7 +52,8 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
isLoading: true,
|
||||
titleTextInputHeight: 20,
|
||||
alarmDialogShown: false,
|
||||
heightBumpView:0
|
||||
heightBumpView:0,
|
||||
noteTagDialogShown: false,
|
||||
};
|
||||
|
||||
// iOS doesn't support multiline text fields properly so disable it
|
||||
@@ -101,6 +103,10 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
this.noteTagDialog_closeRequested = () => {
|
||||
this.setState({ noteTagDialogShown: false });
|
||||
}
|
||||
}
|
||||
|
||||
styles() {
|
||||
@@ -357,6 +363,12 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
shared.toggleIsTodo_onPress(this);
|
||||
}
|
||||
|
||||
tags_onPress() {
|
||||
if (!this.state.note || !this.state.note.id) return;
|
||||
|
||||
this.setState({ noteTagDialogShown: true });
|
||||
}
|
||||
|
||||
setAlarm_onPress() {
|
||||
this.setState({ alarmDialogShown: true });
|
||||
}
|
||||
@@ -393,6 +405,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 +423,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(); } });
|
||||
@@ -535,6 +549,8 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
</View>
|
||||
);
|
||||
|
||||
const noteTagDialog = !this.state.noteTagDialogShown ? null : <NoteTagsDialog onCloseRequested={this.noteTagDialog_closeRequested}/>;
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader
|
||||
@@ -572,6 +588,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
/>
|
||||
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
{ noteTagDialog }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
@@ -3,16 +3,16 @@ const { promiseChain } = require('lib/promise-utils.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
class Database {
|
||||
|
||||
constructor(driver) {
|
||||
this.debugMode_ = false;
|
||||
this.driver_ = driver;
|
||||
this.inTransaction_ = false;
|
||||
|
||||
this.logger_ = new Logger();
|
||||
this.logExcludedQueryTypes_ = [];
|
||||
this.batchTransactionMutex_ = new Mutex();
|
||||
}
|
||||
|
||||
setLogExcludedQueryTypes(v) {
|
||||
@@ -113,29 +113,24 @@ class Database {
|
||||
return;
|
||||
}
|
||||
|
||||
// There can be only one transaction running at a time so queue
|
||||
// any new transaction here.
|
||||
if (this.inTransaction_) {
|
||||
while (true) {
|
||||
await time.msleep(100);
|
||||
if (!this.inTransaction_) {
|
||||
this.inTransaction_ = true;
|
||||
break;
|
||||
}
|
||||
// There can be only one transaction running at a time so use a mutex
|
||||
const release = await this.batchTransactionMutex_.acquire();
|
||||
|
||||
try {
|
||||
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();
|
||||
}
|
||||
|
||||
this.inTransaction_ = true;
|
||||
|
||||
queries.splice(0, 0, 'BEGIN TRANSACTION');
|
||||
queries.push('COMMIT'); // Note: ROLLBACK is currently not supported
|
||||
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
let query = this.wrapQuery(queries[i]);
|
||||
await this.exec(query.sql, query.params);
|
||||
}
|
||||
|
||||
this.inTransaction_ = false;
|
||||
}
|
||||
|
||||
static enumId(type, s) {
|
||||
|
@@ -17,7 +17,7 @@ class FsDriverRN extends FsDriverBase {
|
||||
|
||||
// same as rm -rf
|
||||
async remove(path) {
|
||||
throw new Error('Not implemented');
|
||||
return await this.unlink(path);
|
||||
}
|
||||
|
||||
writeBinaryFile(path, content) {
|
||||
|
@@ -476,12 +476,16 @@ function enexXmlToMdArray(stream, resources) {
|
||||
// </note>
|
||||
// </en-export>
|
||||
|
||||
// Note that there's also the case of resources with no ID where the ID is actually the MD5 of the content.
|
||||
// This is handled in import-enex.js
|
||||
|
||||
let found = false;
|
||||
for (let i = 0; i < remainingResources.length; i++) {
|
||||
let r = remainingResources[i];
|
||||
if (!r.id) {
|
||||
r.id = hash;
|
||||
remainingResources[i] = r;
|
||||
resource = Object.assign({}, r);
|
||||
resource.id = hash;
|
||||
remainingResources.splice(i, 1);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
@@ -490,13 +494,13 @@ function enexXmlToMdArray(stream, resources) {
|
||||
if (!found) {
|
||||
console.warn('Hash with no associated resource: ' + hash);
|
||||
}
|
||||
} else {
|
||||
// If the resource does not appear among the note's resources, it
|
||||
// means it's an attachement. It will be appended along with the
|
||||
// other remaining resources at the bottom of the markdown text.
|
||||
if (!!resource.id) {
|
||||
section.lines = addResourceTag(section.lines, resource, nodeAttributes.alt);
|
||||
}
|
||||
}
|
||||
|
||||
// If the resource does not appear among the note's resources, it
|
||||
// means it's an attachement. It will be appended along with the
|
||||
// other remaining resources at the bottom of the markdown text.
|
||||
if (resource && !!resource.id) {
|
||||
section.lines = addResourceTag(section.lines, resource, nodeAttributes.alt);
|
||||
}
|
||||
} else if (["span", "font", 'sup', 'cite', 'abbr', 'small', 'tt', 'sub', 'colgroup', 'col', 'ins', 'caption', 'var', 'map', 'area'].indexOf(n) >= 0) {
|
||||
// Inline tags that can be ignored in Markdown
|
||||
@@ -545,10 +549,6 @@ function enexXmlToMdArray(stream, resources) {
|
||||
|
||||
if (section.lines.length < 1) throw new Error('Invalid anchor tag closing'); // Sanity check, but normally not possible
|
||||
|
||||
const pushEmptyAnchor = (url) => {
|
||||
section.lines.push('[link](' + url + ')');
|
||||
}
|
||||
|
||||
// When closing the anchor tag, check if there's is any text content. If not
|
||||
// put the URL as is (don't wrap it in [](url)). The markdown parser, using
|
||||
// GitHub flavour, will turn this URL into a link. This is to generate slightly
|
||||
@@ -556,11 +556,11 @@ function enexXmlToMdArray(stream, resources) {
|
||||
let previous = section.lines[section.lines.length - 1];
|
||||
if (previous == '[') {
|
||||
section.lines.pop();
|
||||
pushEmptyAnchor(url);
|
||||
section.lines.push(url);
|
||||
} else if (!previous || previous == url) {
|
||||
section.lines.pop();
|
||||
section.lines.pop();
|
||||
pushEmptyAnchor(url);
|
||||
section.lines.push(url);
|
||||
} else {
|
||||
// Need to remove any new line character between the current ']' and the previous '['
|
||||
// otherwise it won't render properly.
|
||||
@@ -583,8 +583,7 @@ function enexXmlToMdArray(stream, resources) {
|
||||
const c = section.lines.pop();
|
||||
if (c === '[') break;
|
||||
}
|
||||
//section.lines.push(url);
|
||||
pushEmptyAnchor(url);
|
||||
section.lines.push(url);
|
||||
} else {
|
||||
section.lines.push('](' + url + ')');
|
||||
}
|
||||
@@ -644,7 +643,6 @@ function drawTable(table) {
|
||||
// https://gist.github.com/IanWang/28965e13cdafdef4e11dc91f578d160d#tables
|
||||
|
||||
const flatRender = tableHasSubTables(table); // Render the table has regular text
|
||||
const minColWidth = 3;
|
||||
let lines = [];
|
||||
lines.push(BLOCK_OPEN);
|
||||
let headerDone = false;
|
||||
@@ -687,9 +685,16 @@ function drawTable(table) {
|
||||
|
||||
// A cell in a Markdown table cannot have actual new lines so replace
|
||||
// them with <br>, which are supported by the markdown renderers.
|
||||
const cellText = processMdArrayNewLines(td.lines).replace(/\n+/g, "<br>");
|
||||
let cellText = processMdArrayNewLines(td.lines).replace(/\n+/g, "<br>");
|
||||
|
||||
const width = Math.max(cellText.length, 3);
|
||||
// Inside tables cells, "|" needs to be escaped
|
||||
cellText = cellText.replace(/\|/g, "\\|");
|
||||
|
||||
// Previously the width of the cell was as big as the content since it looks nicer, however that often doesn't work
|
||||
// since the content can be very long, resulting in unreadable markdown. So no solution is perfect but making it a
|
||||
// width of 3 is a bit better. Note that 3 is the minimum width of a cell - below this, it won't be rendered by
|
||||
// markdown parsers.
|
||||
const width = 3;
|
||||
line.push(stringPadding(cellText, width, ' ', stringPadding.RIGHT));
|
||||
|
||||
if (!headerDone) {
|
||||
|
@@ -11,6 +11,7 @@ const { enexXmlToMd } = require('./import-enex-md-gen.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const Levenshtein = require('levenshtein');
|
||||
const jsSHA = require("jssha");
|
||||
const md5 = require('md5');
|
||||
|
||||
//const Promise = require('promise');
|
||||
const fs = require('fs-extra');
|
||||
@@ -30,8 +31,8 @@ function extractRecognitionObjId(recognitionXml) {
|
||||
return r && r.length >= 2 ? r[1] : null;
|
||||
}
|
||||
|
||||
function filePutContents(filePath, content) {
|
||||
return fs.writeFile(filePath, content);
|
||||
async function filePutContents(filePath, content) {
|
||||
await fs.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
function removeUndefinedProperties(note) {
|
||||
@@ -255,49 +256,6 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
stream.resume();
|
||||
processingNotes = false;
|
||||
return true;
|
||||
|
||||
// let chain = [];
|
||||
// while (notes.length) {
|
||||
// let note = notes.shift();
|
||||
// const contentStream = stringToStream(note.bodyXml);
|
||||
// chain.push(() => {
|
||||
// return enexXmlToMd(contentStream, note.resources).then((body) => {
|
||||
// delete note.bodyXml;
|
||||
|
||||
// // console.info('-----------------------------------------------------------');
|
||||
// // console.info(body);
|
||||
// // console.info('-----------------------------------------------------------');
|
||||
|
||||
// note.id = uuid.create();
|
||||
// note.parent_id = parentFolderId;
|
||||
// note.body = body;
|
||||
|
||||
// // Notes in enex files always have a created timestamp but not always an
|
||||
// // updated timestamp (it the note has never been modified). For sync
|
||||
// // we require an updated_time property, so set it to create_time in that case
|
||||
// if (!note.updated_time) note.updated_time = note.created_time;
|
||||
|
||||
// return saveNoteToStorage(note, importOptions.fuzzyMatching);
|
||||
// }).then((result) => {
|
||||
// if (result.noteUpdated) {
|
||||
// progressState.updated++;
|
||||
// } else if (result.noteCreated) {
|
||||
// progressState.created++;
|
||||
// } else if (result.noteSkipped) {
|
||||
// progressState.skipped++;
|
||||
// }
|
||||
// progressState.resourcesCreated += result.resourcesCreated;
|
||||
// progressState.notesTagged += result.notesTagged;
|
||||
// importOptions.onProgress(progressState);
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// return promiseChain(chain).then(() => {
|
||||
// stream.resume();
|
||||
// processingNotes = false;
|
||||
// return true;
|
||||
// });
|
||||
}
|
||||
|
||||
saxStream.on('error', (error) => {
|
||||
@@ -418,6 +376,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
noteAttributes = null;
|
||||
} else if (n == 'resource') {
|
||||
let decodedData = null;
|
||||
let resourceId = noteResource.id;
|
||||
if (noteResource.dataEncoding == 'base64') {
|
||||
try {
|
||||
decodedData = Buffer.from(noteResource.data, 'base64');
|
||||
@@ -429,8 +388,14 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
decodedData = noteResource.data; // Just put the encoded data directly in the file so it can, potentially, be manually decoded later
|
||||
}
|
||||
|
||||
if (!resourceId && decodedData) {
|
||||
// If no resource ID is present, the resource ID is actually the MD5 of the data.
|
||||
// This ID will match the "hash" attribute of the corresponding <en-media> tag.
|
||||
resourceId = md5(decodedData);
|
||||
}
|
||||
|
||||
let r = {
|
||||
id: noteResource.id,
|
||||
id: resourceId,
|
||||
data: decodedData,
|
||||
mime: noteResource.mime,
|
||||
title: noteResource.filename ? noteResource.filename : '',
|
||||
|
@@ -336,6 +336,9 @@ class JoplinDatabase extends Database {
|
||||
}
|
||||
|
||||
if (targetVersion == 11) {
|
||||
// This trick was needed because Electron Builder incorrectly released a dev branch containing v10 as it was
|
||||
// still being developed, and the db schema was not final at that time. So this v11 was created to
|
||||
// make sure any invalid db schema that was accidentally created was deleted and recreated.
|
||||
queries.push('DROP TABLE item_changes');
|
||||
queries.push('DROP TABLE note_resources');
|
||||
upgradeVersion10();
|
||||
|
@@ -31,7 +31,17 @@ class NoteResource extends BaseModel {
|
||||
queries.push({ sql: 'INSERT INTO note_resources (note_id, resource_id, is_associated, last_seen_time) VALUES (?, ?, ?, ?)', params: [noteId, notProcessedResourceIds[i], 1, Date.now()] });
|
||||
}
|
||||
|
||||
await this.db().transactionExecBatch(queries);
|
||||
await this.db().transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
static async addOrphanedResources() {
|
||||
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].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);
|
||||
}
|
||||
|
||||
static async remove(noteId) {
|
||||
@@ -41,10 +51,20 @@ class NoteResource extends BaseModel {
|
||||
static async orphanResources(expiryDelay = null) {
|
||||
if (expiryDelay === null) expiryDelay = 1000 * 60 * 60 * 24;
|
||||
const cutOffTime = Date.now() - expiryDelay;
|
||||
const output = await this.modelSelectAll('SELECT DISTINCT resource_id FROM note_resources WHERE is_associated = 0 AND last_seen_time < ?', [cutOffTime]);
|
||||
const output = await this.modelSelectAll(`
|
||||
SELECT resource_id, sum(is_associated)
|
||||
FROM note_resources
|
||||
GROUP BY resource_id
|
||||
HAVING sum(is_associated) <= 0
|
||||
AND last_seen_time < ?
|
||||
`, [cutOffTime]);
|
||||
return output.map(r => r.resource_id);
|
||||
}
|
||||
|
||||
static async deleteByResource(resourceId) {
|
||||
await this.db().exec('DELETE FROM note_resources WHERE resource_id = ?', [resourceId]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = NoteResource;
|
@@ -1,5 +1,6 @@
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const NoteResource = require('lib/models/NoteResource.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const ArrayUtils = require('lib/ArrayUtils.js');
|
||||
const pathUtils = require('lib/path-utils.js');
|
||||
@@ -152,7 +153,8 @@ class Resource extends BaseItem {
|
||||
const resource = await Resource.load(id);
|
||||
const path = Resource.fullPath(resource);
|
||||
await this.fsDriver().remove(path);
|
||||
await super.batchDelete([id], options)
|
||||
await super.batchDelete([id], options);
|
||||
await NoteResource.deleteByResource(id); // Clean up note/resource relationships
|
||||
}
|
||||
}
|
||||
|
||||
|
23
ReactNativeClient/lib/models/Search.js
Normal file
23
ReactNativeClient/lib/models/Search.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
|
||||
class Search extends BaseModel {
|
||||
|
||||
static tableName() {
|
||||
throw new Error('Not using database');
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_SEARCH;
|
||||
}
|
||||
|
||||
static keywords(query) {
|
||||
let output = query.trim();
|
||||
output = output.split(/[\s\t\n]+/);
|
||||
output = output.filter(o => !!o);
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Search;
|
@@ -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) {
|
||||
|
@@ -426,11 +426,35 @@ const reducer = (state = defaultState, action) => {
|
||||
case 'SEARCH_ADD':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
let searches = newState.searches.slice();
|
||||
var searches = newState.searches.slice();
|
||||
searches.push(action.search);
|
||||
newState.searches = searches;
|
||||
break;
|
||||
|
||||
case 'SEARCH_UPDATE':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
var searches = newState.searches.slice();
|
||||
var found = false;
|
||||
for (let i = 0; i < searches.length; i++) {
|
||||
if (searches[i].id === action.search.id) {
|
||||
searches[i] = Object.assign({}, action.search);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) searches.push(action.search);
|
||||
|
||||
if (!action.search.query_pattern) {
|
||||
newState.notesParentType = defaultNotesParentType(state, 'Search');
|
||||
} else {
|
||||
newState.notesParentType = 'Search';
|
||||
}
|
||||
|
||||
newState.searches = searches;
|
||||
break;
|
||||
|
||||
case 'SEARCH_DELETE':
|
||||
|
||||
newState = handleItemDelete(state, action);
|
||||
|
@@ -62,6 +62,8 @@ class ResourceService extends BaseService {
|
||||
await ItemChange.db().exec('DELETE FROM item_changes WHERE id <= ?', [lastId]);
|
||||
}
|
||||
|
||||
await NoteResource.addOrphanedResources();
|
||||
|
||||
this.logger().info('ResourceService::indexNoteResources: Completed');
|
||||
}
|
||||
|
||||
|
1
ReactNativeClient/locales/da_DK.json
Normal file
1
ReactNativeClient/locales/da_DK.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
var locales = {};
|
||||
locales['en_GB'] = require('./en_GB.json');
|
||||
locales['da_DK'] = require('./da_DK.json');
|
||||
locales['de_DE'] = require('./de_DE.json');
|
||||
locales['es_ES'] = require('./es_ES.json');
|
||||
locales['eu'] = require('./eu.json');
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5
ReactNativeClient/package-lock.json
generated
5
ReactNativeClient/package-lock.json
generated
@@ -6533,6 +6533,11 @@
|
||||
"strip-ansi": "3.0.1"
|
||||
}
|
||||
},
|
||||
"string-natural-compare": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-2.0.2.tgz",
|
||||
"integrity": "sha1-xc5OJ4q10SZa5vxVQ1rre3b8sAE="
|
||||
},
|
||||
"string-width": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
|
||||
|
@@ -46,6 +46,7 @@
|
||||
"react-redux": "4.4.8",
|
||||
"redux": "3.6.0",
|
||||
"stream": "0.0.2",
|
||||
"string-natural-compare": "^2.0.2",
|
||||
"timers": "^0.1.1",
|
||||
"url-parse": "^1.2.0",
|
||||
"uuid": "^3.0.1",
|
||||
|
@@ -127,9 +127,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') return false;
|
||||
if (route.routeName === 'Folder') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -172,7 +172,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.
|
||||
|
@@ -76,6 +76,10 @@ const headerHtml = `<!doctype html>
|
||||
table.screenshots td {
|
||||
border: 1px solid #C2C2C2;
|
||||
}
|
||||
img[align="left"] {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-screenshot {
|
||||
height: 40em;
|
||||
padding: 1em;
|
||||
|
@@ -110,4 +110,5 @@ async function main() {
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
@@ -28,4 +28,5 @@ async function main() {
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
@@ -71,6 +71,10 @@
|
||||
table.screenshots td {
|
||||
border: 1px solid #C2C2C2;
|
||||
}
|
||||
img[align="left"] {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-screenshot {
|
||||
height: 40em;
|
||||
padding: 1em;
|
||||
|
@@ -71,6 +71,10 @@
|
||||
table.screenshots td {
|
||||
border: 1px solid #C2C2C2;
|
||||
}
|
||||
img[align="left"] {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-screenshot {
|
||||
height: 40em;
|
||||
padding: 1em;
|
||||
|
@@ -71,6 +71,10 @@
|
||||
table.screenshots td {
|
||||
border: 1px solid #C2C2C2;
|
||||
}
|
||||
img[align="left"] {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-screenshot {
|
||||
height: 40em;
|
||||
padding: 1em;
|
||||
|
@@ -71,6 +71,10 @@
|
||||
table.screenshots td {
|
||||
border: 1px solid #C2C2C2;
|
||||
}
|
||||
img[align="left"] {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-screenshot {
|
||||
height: 40em;
|
||||
padding: 1em;
|
||||
|
BIN
docs/images/nextcloud-logo-background.png
Normal file
BIN
docs/images/nextcloud-logo-background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
@@ -71,6 +71,10 @@
|
||||
table.screenshots td {
|
||||
border: 1px solid #C2C2C2;
|
||||
}
|
||||
img[align="left"] {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-screenshot {
|
||||
height: 40em;
|
||||
padding: 1em;
|
||||
@@ -203,9 +207,10 @@
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=E8JMYD2LQ8MMA&lc=GB&item_name=Joplin+Development&currency_code=EUR&bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://img.shields.io/badge/Donate-PayPal-green.svg" alt="Donate"></a> <a href="https://en.cryptobadges.io/donate/1AnbeRd5NZT1ssG93jXzaDoHwzgjQAHX3R"><img src="https://en.cryptobadges.io/badge/micro/1AnbeRd5NZT1ssG93jXzaDoHwzgjQAHX3R" alt="Donate with Bitcoin"></a> <a href="https://travis-ci.org/laurent22/joplin"><img src="https://travis-ci.org/laurent22/joplin.svg?branch=master" alt="Travis Build Status"></a> <a href="https://ci.appveyor.com/project/laurent22/joplin"><img src="https://ci.appveyor.com/api/projects/status/github/laurent22/joplin?branch=master&passingText=master%20-%20OK&svg=true" alt="Appveyor Build Status"></a></p>
|
||||
<p>Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in <a href="#markdown">Markdown format</a>.</p>
|
||||
<p>Notes exported from Evernote via .enex files <a href="#importing">can be imported</a> into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.</p>
|
||||
<p>The notes can be <a href="#synchronisation">synchronised</a> with various targets including <a href="https://nextcloud.com/">Nextcloud</a>, the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.</p>
|
||||
<p>The notes can be <a href="#synchronisation">synchronised</a> with various cloud services including <a href="https://nextcloud.com/">Nextcloud</a>, the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.</p>
|
||||
<p>The UI of the terminal client is built on top of the great <a href="https://github.com/cronvel/terminal-kit">terminal-kit</a> library, the desktop client using <a href="https://electronjs.org/">Electron</a>, and the Android client front end is done using <a href="https://facebook.github.io/react-native/">React Native</a>.</p>
|
||||
<div class="top-screenshot"><img src="https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/AllClients.jpg" style="max-width: 100%; max-height: 35em;"></div>
|
||||
|
||||
@@ -222,15 +227,15 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Windows</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.70/Joplin-Setup-1.0.70.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.78/Joplin-Setup-1.0.78.exe'><img alt='Get it on Windows' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeWindows.png'/></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>macOS</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.70/Joplin-1.0.70.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.78/Joplin-1.0.78.dmg'><img alt='Get it on macOS' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeMacOS.png'/></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.70/Joplin-1.0.70-x86_64.AppImage'><img alt='Get it on Linux' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.0.78/Joplin-1.0.78-x86_64.AppImage'><img alt='Get it on Linux' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeLinux.png'/></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -247,7 +252,7 @@
|
||||
<tr>
|
||||
<td>Android</td>
|
||||
<td><a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a></td>
|
||||
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.107/joplin-v1.0.107.apk">Download APK File</a></td>
|
||||
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.112/joplin-v1.0.112.apk">Download APK File</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>iOS</td>
|
||||
@@ -311,7 +316,8 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
<p>One of the goals of Joplin was to avoid being tied to any particular company or service, whether it is Evernote, Google or Microsoft. As such the synchronisation is designed without any hard dependency to any particular service. Most of the synchronisation process is done at an abstract level and access to external services, such as Nextcloud or OneDrive, is done via lightweight drivers. It is easy to support new services by creating simple drivers that provide a filesystem-like interface, i.e. the ability to read, write, delete and list items. It is also simple to switch from one service to another or to even sync to multiple services at once. Each note, notebook, tags, as well as the relation between items is transmitted as plain text files during synchronisation, which means the data can also be moved to a different application, can be easily backed up, inspected, etc.</p>
|
||||
<p>Currently, synchronisation is possible with Nextcloud and OneDrive (by default) or the local filesystem. A Dropbox one will also be available once <a href="https://github.com/facebook/react-native/issues/14445">this React Native bug</a> is fixed. To setup synchronisation please follow the instructions below. After that, the application will synchronise in the background whenever it is running, or you can click on "Synchronise" to start a synchronisation manually.</p>
|
||||
<h2 id="nextcloud-synchronisation">Nextcloud synchronisation</h2>
|
||||
<p>On the <strong>desktop application</strong> or <strong>mobile application</strong>, go to the config screen and select Nextcloud as the synchronisation target. Then input <a href="https://docs.nextcloud.com/server/9/user_manual/files/access_webdav.html">the WebDAV URL</a>, this is normally <code>https://example.com/nextcloud/remote.php/dav/files/USERNAME/Joplin</code> (<strong>make sure to create the "Joplin" directory in Nextcloud</strong> and to replace USERNAME by your Nextcloud username), and set the username and password. If it does not work, please <a href="https://github.com/laurent22/joplin/issues/61#issuecomment-373282608">see this explanation</a> for more details.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/nextcloud-logo-background.png" width="100" align="left"> <a href="https://nextcloud.com/">Nextcloud</a> is a self-hosted, private cloud solution. It can store documents, images and videos but also calendars, passwords and countless other things and can sync them to your laptop or phone. As you can host your own Nextcloud server, you own both the data on your device and infrastructure used for synchronisation. As such it is a good fit for Joplin. The platform is also well supported and with a strong community, so it is likely to be around for a while - since it's open source anyway, it is not a service that can be closed, it can exist on a server for as long as one chooses.</p>
|
||||
<p>On the <strong>desktop application</strong> or <strong>mobile application</strong>, go to the config screen and select Nextcloud as the synchronisation target. Then input the WebDAV URL (to get it, click on Settings in the bottom left corner of the page, in Nextcloud), this is normally <code>https://example.com/nextcloud/remote.php/webdav/Joplin</code> (<strong>make sure to create the "Joplin" directory in Nextcloud</strong>), and set the username and password. If it does not work, please <a href="https://github.com/laurent22/joplin/issues/61#issuecomment-373282608">see this explanation</a> for more details.</p>
|
||||
<p>On the <strong>terminal application</strong>, you will need to set the <code>sync.target</code> config variable and all the <code>sync.5.path</code>, <code>sync.5.username</code> and <code>sync.5.password</code> config variables to, respectively the Nextcloud WebDAV URL, your username and your password. This can be done from the command line mode using:</p>
|
||||
<pre><code>:config sync.5.path https://example.com/nextcloud/remote.php/dav/files/USERNAME/Joplin
|
||||
:config sync.5.username YOUR_USERNAME
|
||||
@@ -320,10 +326,11 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
</code></pre><p>If synchronisation does not work, please consult the logs in the app profile directory - it is often due to a misconfigured URL or password. The log should indicate what the exact issue is.</p>
|
||||
<h2 id="webdav-synchronisation">WebDAV synchronisation</h2>
|
||||
<p>Select the "WebDAV" synchronisation target and follow the same instructions as for Nextcloud above.</p>
|
||||
<p>WebDAV-compatible services that are known to work with Joplin:</p>
|
||||
<p>WebDAV-compatible services that are known to work with Joplin.</p>
|
||||
<ul>
|
||||
<li><a href="https://www.box.com/">Box.com</a></li>
|
||||
<li><a href="https://www.drivehq.com">DriveHQ</a></li>
|
||||
<li><a href="https://www.strato.fr/stockage-en-ligne/">HiDrive</a> from Strato. <a href="https://github.com/laurent22/joplin/issues/309">Setup help</a></li>
|
||||
<li><a href="https://owncloud.org/">OwnCloud</a></li>
|
||||
<li><a href="https://www.seafile.com/">Seafile</a></li>
|
||||
<li><a href="https://www.transip.nl/stack/">Stack</a></li>
|
||||
@@ -409,15 +416,22 @@ $$
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/hr.png" alt=""></td>
|
||||
<td>Croatian</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po">hr_HR</a></td>
|
||||
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
||||
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
||||
<td>64%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/dk.png" alt=""></td>
|
||||
<td>Dansk</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/da_DK.po">da_DK</a></td>
|
||||
<td></td>
|
||||
<td>99%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/de.png" alt=""></td>
|
||||
<td>Deutsch</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po">de_DE</a></td>
|
||||
<td>Tobias Grasse <a href="mailto:mail@tobias-grasse.net">mail@tobias-grasse.net</a></td>
|
||||
<td>99%</td>
|
||||
<td>Tobias Grasse <a href="mailto:mail@tobias-grasse.net">mail@tobias-grasse.net</a></td>
|
||||
<td>98%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/gb.png" alt=""></td>
|
||||
@@ -430,7 +444,7 @@ $$
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/es.png" alt=""></td>
|
||||
<td>Español</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po">es_ES</a></td>
|
||||
<td>Fernando Martín <a href="mailto:f@mrtn.es">f@mrtn.es</a></td>
|
||||
<td>Fernando Martín <a href="mailto:f@mrtn.es">f@mrtn.es</a></td>
|
||||
<td>99%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -438,7 +452,7 @@ $$
|
||||
<td>Français</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po">fr_FR</a></td>
|
||||
<td>Laurent Cozic</td>
|
||||
<td>99%</td>
|
||||
<td>100%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/it.png" alt=""></td>
|
||||
@@ -452,27 +466,27 @@ $$
|
||||
<td>Nederlands</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po">nl_BE</a></td>
|
||||
<td></td>
|
||||
<td>80%</td>
|
||||
<td>79%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/br.png" alt=""></td>
|
||||
<td>Português (Brasil)</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po">pt_BR</a></td>
|
||||
<td>Renato Nunes Bastos <a href="mailto:rnbastos@gmail.com">rnbastos@gmail.com</a></td>
|
||||
<td>98%</td>
|
||||
<td>Renato Nunes Bastos <a href="mailto:rnbastos@gmail.com">rnbastos@gmail.com</a></td>
|
||||
<td>97%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/ru.png" alt=""></td>
|
||||
<td>Русский</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po">ru_RU</a></td>
|
||||
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
||||
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
||||
<td>99%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/cn.png" alt=""></td>
|
||||
<td>中文 (简体)</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/zh_CN.po">zh_CN</a></td>
|
||||
<td>RCJacH <a href="mailto:RCJacH@outlook.com">RCJacH@outlook.com</a></td>
|
||||
<td>RCJacH <a href="mailto:RCJacH@outlook.com">RCJacH@outlook.com</a></td>
|
||||
<td>66%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -488,7 +502,7 @@ $$
|
||||
<h1 id="known-bugs">Known bugs</h1>
|
||||
<ul>
|
||||
<li>Non-alphabetical characters such as Chinese or Arabic might create glitches in the terminal on Windows. This is a limitation of the current Windows console.</li>
|
||||
<li>While the mobile can sync and load tags, it is not currently possible to create new ones. The desktop and terminal apps can create, delete and edit tags.</li>
|
||||
<li>It is only possible to upload files of up to 4MB to OneDrive due to a limitation of <a href="https://docs.microsoft.com/en-gb/onedrive/developer/rest-api/api/driveitem_put_content">the API</a> being currently used. There is currently no plan to support OneDrive "large file" API.</li>
|
||||
</ul>
|
||||
<h1 id="license">License</h1>
|
||||
<p>MIT License</p>
|
||||
|
@@ -71,6 +71,10 @@
|
||||
table.screenshots td {
|
||||
border: 1px solid #C2C2C2;
|
||||
}
|
||||
img[align="left"] {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-screenshot {
|
||||
height: 40em;
|
||||
padding: 1em;
|
||||
|
Reference in New Issue
Block a user