Removed build files

pull/1524/head
Laurent Cozic 2019-05-13 00:43:12 +01:00
parent a82f8c7dd0
commit 3d333bd8f2
259 changed files with 0 additions and 33442 deletions

View File

@ -1,405 +0,0 @@
# Joplin
[![Donate](https://joplinapp.org/images/badges/Donate-PayPal-green.svg)](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) [![Become a patron](https://joplinapp.org/images/badges/Patreon-Badge.svg)](https://www.patreon.com/joplin) [![Travis Build Status](https://travis-ci.org/laurent22/joplin.svg?branch=master)](https://travis-ci.org/laurent22/joplin) [![Appveyor Build Status](https://ci.appveyor.com/api/projects/status/github/laurent22/joplin?branch=master&passingText=master%20-%20OK&svg=true)](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 cloud services including [Nextcloud](https://nextcloud.com/), Dropbox, OneDrive, WebDAV or the file system (for example with a network directory). 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 application is available for Windows, Linux, macOS, Android and iOS. A [Web Clipper](https://github.com/laurent22/joplin/blob/master/readme/clipper.md), to save web pages and screenshots from your browser, is also available for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/joplin-web-clipper/) and [Chrome](https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek?hl=en-GB).
<div class="top-screenshot"><img src="https://joplinapp.org/images/AllClients.jpg" style="max-width: 100%; max-height: 35em;"></div>
# Installation
Three types of applications are available: for the **desktop** (Windows, macOS and Linux), for **mobile** (Android and iOS) and for **terminal** (Windows, macOS and Linux). All applications have similar user interfaces and can synchronise with each other.
## Desktop applications
Operating System | Download | Alternative
-----------------|--------|-------------------
Windows (32 and 64-bit) | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.143/Joplin-Setup-1.0.143.exe'><img alt='Get it on Windows' height="40px" src='https://joplinapp.org/images/BadgeWindows.png'/></a> | or Get the <a href='https://github.com/laurent22/joplin/releases/download/v1.0.143/JoplinPortable.exe'>Portable version</a><br>(to run from a USB key, etc.)
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.143/Joplin-1.0.143.dmg'><img alt='Get it on macOS' height="40px" src='https://joplinapp.org/images/BadgeMacOS.png'/></a> |
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v1.0.143/Joplin-1.0.143-x86_64.AppImage'><img alt='Get it on Linux' height="40px" src='https://joplinapp.org/images/BadgeLinux.png'/></a> | An Arch Linux package<br>[is also available](#terminal-application).
The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
On Linux, if it works with your distribution (it has been tested on Ubuntu, Fedora, Gnome and Mint), the recommended way is to use this script as it will handle the desktop icon too:
``` sh
wget -O - https://raw.githubusercontent.com/laurent22/joplin/master/Joplin_install_and_update.sh | bash
```
## 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://joplinapp.org/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.243/joplin-v1.0.243.apk)
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://joplinapp.org/images/BadgeIOS.png'/></a> | -
## Terminal application
Operating system | Method
-----------------|----------------
macOS | `brew install joplin`
Linux or Windows (via [WSL](https://msdn.microsoft.com/en-us/commandline/wsl/faq?f=255&MSPPError=-2147217396)) | **Important:** First, [install Node 8+](https://nodejs.org/en/download/package-manager/). Node 8 is LTS but not yet available everywhere so you might need to manually install it.<br/><br/>`NPM_CONFIG_PREFIX=~/.joplin-bin npm install -g joplin`<br/>`sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin`<br><br>By default, the application binary will be installed under `~/.joplin-bin`. You may change this directory if needed. Alternatively, if your npm permissions are setup as described [here](https://docs.npmjs.com/getting-started/fixing-npm-permissions#option-2-change-npms-default-directory-to-another-directory) (Option 2) then simply running `npm -g install joplin` would work.
Arch Linux | An Arch Linux package is available [here](https://aur.archlinux.org/packages/joplin/). To install it, use an AUR wrapper such as yay: `yay -S joplin`. Both the CLI tool (type `joplin`) and desktop app (type `joplin-desktop`) are packaged. For support, please go to the [GitHub repo](https://github.com/masterkorp/joplin-pkgbuild).
To start it, type `joplin`.
For usage information, please refer to the full [Joplin Terminal Application Documentation](https://joplinapp.org/terminal/).
## Web Clipper
The Web Clipper is a browser extension that allows you to save web pages and screenshots from your browser. For more information on how to install and use it, see the [Web Clipper Help Page](https://github.com/laurent22/joplin/blob/master/readme/clipper.md).
<!-- TOC -->
# Table of contents
- Applications
- [Desktop application](https://github.com/laurent22/joplin/blob/master/readme/desktop.md)
- [Mobile applications](https://github.com/laurent22/joplin/blob/master/readme/mobile.md)
- [Terminal application](https://github.com/laurent22/joplin/blob/master/readme/terminal.md)
- [Web Clipper](https://github.com/laurent22/joplin/blob/master/readme/clipper.md)
- Support
- [Joplin Forum](https://discourse.joplin.cozic.net)
- [How to enable end-to-end encryption](https://github.com/laurent22/joplin/blob/master/readme/e2ee.md)
- [End-to-end encryption spec](https://github.com/laurent22/joplin/blob/master/readme/spec.md)
- [How to enable debug mode](https://github.com/laurent22/joplin/blob/master/readme/debugging.md)
- [API documentation](https://github.com/laurent22/joplin/blob/master/readme/api.md)
- [FAQ](https://github.com/laurent22/joplin/blob/master/readme/faq.md)
- About
- [Changelog](https://github.com/laurent22/joplin/blob/master/readme/changelog.md)
- [Stats](https://github.com/laurent22/joplin/blob/master/readme/stats.md)
- [Donate](https://github.com/laurent22/joplin/blob/master/readme/donate.md)
<!-- TOC -->
# Features
- Desktop, mobile and terminal applications.
- [Web Clipper](https://github.com/laurent22/joplin/blob/master/readme/clipper.md) for Firefox and Chrome.
- End To End Encryption (E2EE)
- Synchronisation with various services, including NextCloud, Dropbox, WebDAV and OneDrive.
- Import Enex files (Evernote export format) and Markdown files.
- Export JEX files (Joplin Export format) and raw files.
- Support notes, to-dos, tags and notebooks.
- Sort notes by multiple criteria - title, updated time, etc.
- Support for alarms (notifications) in mobile and desktop applications.
- Offline first, so the entire data is always available on the device even without an internet connection.
- Markdown notes, which are rendered with images and formatting in the desktop and mobile applications. Support for extra features such as math notation and checkboxes.
- File attachment support - images are displayed, and other files are linked and can be opened in the relevant application.
- Search functionality.
- Geo-location support.
- Supports multiple languages
- External editor support - open notes in your favorite external editor with one click in Joplin.
# Importing
## Importing from Evernote
Joplin was designed as a replacement for Evernote and so can import complete Evernote notebooks, as well as notes, tags, resources (attached files) and note metadata (such as author, geo-location, etc.) via ENEX files. In terms of data, the only two things that might slightly differ are:
- Recognition data - Evernote images, in particular scanned (or photographed) documents have [recognition data](https://en.wikipedia.org/wiki/Optical_character_recognition) associated with them. It is the text that Evernote has been able to recognise in the document. This data is not preserved when the note are imported into Joplin. However, should it become supported in the search tool or other parts of Joplin, it should be possible to regenerate this recognition data since the actual image would still be available.
- Colour, font sizes and faces - Evernote text is stored as HTML and this is converted to Markdown during the import process. For notes that are mostly plain text or with basic formatting (bold, italic, bullet points, links, etc.) this is a lossless conversion, and the note, once rendered back to HTML should be very similar. Tables are also imported and converted to Markdown tables. For very complex notes, some formatting data might be lost - in particular colours, font sizes and font faces will not be imported. The text itself however is always imported in full regardless of formatting.
To import Evernote data, first export your Evernote notebooks to ENEX files as described [here](https://help.evernote.com/hc/en-us/articles/209005557-How-to-back-up-export-and-restore-import-notes-and-notebooks). Then follow these steps:
On the **desktop application**, open File > Import > ENEX and select your file. The notes will be imported into a new separate notebook. If needed they can then be moved to a different notebook, or the notebook can be renamed, etc.
On the **terminal application**, in [command-line mode](https://joplinapp.org/terminal/#command-line-mode), type `import /path/to/file.enex`. This will import the notes into a new notebook named after the filename.
## Importing from Markdown files
Joplin can import notes from plain Markdown file. You can either import a complete directory of Markdown files or individual files.
On the **desktop application**, open File > Import > MD and select your Markdown file or directory.
On the **terminal application**, in [command-line mode](https://joplinapp.org/terminal/#command-line-mode), type `import --format md /path/to/file.md` or `import --format md /path/to/directory/`.
## Importing from other applications
In general the way to import notes from any application into Joplin is to convert the notes to ENEX files (Evernote format) and to import these ENEX files into Joplin using the method above. Most note-taking applications support ENEX files so it should be relatively straightforward. For help about specific applications, see below:
* Standard Notes: Please see [this tutorial](https://programadorwebvalencia.com/migrate-notes-from-standard-notes-to-joplin/)
* Tomboy Notes: Export the notes to ENEX files [as described here](https://askubuntu.com/questions/243691/how-can-i-export-my-tomboy-notes-into-evernote/608551) for example, and import these ENEX files into Joplin.
* OneNote: First [import the notes from OneNote into Evernote](https://discussion.evernote.com/topic/107736-is-there-a-way-to-import-from-onenote-into-evernote-on-the-mac/). Then export the ENEX file from Evernote and import it into Joplin.
* NixNote: Synchronise with Evernote, then export the ENEX files and import them into Joplin. More info [in this thread](https://discourse.joplin.cozic.net/t/import-from-nixnote/183/3).
# Exporting
Joplin can export to the JEX format (Joplin Export file), which is a tar file that can contain multiple notes, notebooks, etc. This is a lossless format in that all the notes, but also metadata such as geo-location, updated time, tags, etc. are preserved. This format is convenient for backup purposes and can be re-imported into Joplin. A "raw" format is also available. This is the same as the JEX format except that the data is saved to a directory and each item represented by a single file.
# Synchronisation
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 Dropbox, 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.
Currently, synchronisation is possible with Nextcloud, Dropbox (by default), OneDrive or the local filesystem. 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.
## Nextcloud synchronisation
<img src="https://joplinapp.org/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:
:config sync.5.path https://example.com/nextcloud/remote.php/webdav/Joplin
:config sync.5.username YOUR_USERNAME
:config sync.5.password YOUR_PASSWORD
:config sync.target 5
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.
## Dropbox synchronisation
When syncing with Dropbox, Joplin creates a sub-directory in Dropbox, in `/Apps/Joplin` and read/write the notes and notebooks from it. The application does not have access to anything outside this directory.
On the **desktop application** or **mobile application**, select "Dropbox" as the synchronisation target in the config screen (it is selected by default). Then, to initiate the synchronisation process, click on the "Synchronise" button in the sidebar and follow the instructions.
On the **terminal application**, to initiate the synchronisation process, type `:sync`. You will be asked to follow a link to authorise the application. It is possible to also synchronise outside of the user interface by typing `joplin sync` from the terminal. This can be used to setup a cron script to synchronise at regular interval. For example, this would do it every 30 minutes:
*/30 * * * * /path/to/joplin sync
## WebDAV synchronisation
Select the "WebDAV" synchronisation target and follow the same instructions as for Nextcloud above.
WebDAV-compatible services that are known to work with Joplin:
- [Apache WebDAV Module](https://httpd.apache.org/docs/current/mod/mod_dav.html)
- [Box.com](https://www.box.com/)
- [DriveHQ](https://www.drivehq.com)
- [Fastmail](https://www.fastmail.com/)
- [HiDrive](https://www.strato.fr/stockage-en-ligne/) from Strato. [Setup help](https://github.com/laurent22/joplin/issues/309)
- [Nginx WebDAV Module](https://nginx.org/en/docs/http/ngx_http_dav_module.html)
- [NextCloud](https://nextcloud.com/)
- [OwnCloud](https://owncloud.org/)
- [Seafile](https://www.seafile.com/)
- [Stack](https://www.transip.nl/stack/)
- [WebDAV Nav](https://www.schimera.com/products/webdav-nav-server/), a macOS server.
- [Zimbra](https://www.zimbra.com/)
## OneDrive synchronisation
When syncing with OneDrive, Joplin creates a sub-directory in OneDrive, in /Apps/Joplin and read/write the notes and notebooks from it. The application does not have access to anything outside this directory.
On the **desktop application** or **mobile application**, select "OneDrive" as the synchronisation target in the config screen. Then, to initiate the synchronisation process, click on the "Synchronise" button in the sidebar and follow the instructions.
On the **terminal application**, to initiate the synchronisation process, type `:sync`. You will be asked to follow a link to authorise the application (simply input your Microsoft credentials - you do not need to register with OneDrive).
# Encryption
Joplin supports end-to-end encryption (E2EE) on all the applications. E2EE is a system where only the owner of the notes, notebooks, tags or resources can read them. It prevents potential eavesdroppers - including telecom providers, internet providers, and even the developers of Joplin from being able to access the data. Please see the [End-To-End Encryption Tutorial](https://joplinapp.org/e2ee/) for more information about this feature and how to enable it.
For a more technical description, mostly relevant for development or to review the method being used, please see the [Encryption specification](https://joplinapp.org/spec/).
# External text editor
Joplin notes can be opened and edited using an external editor of your choice. It can be a simple text editor like Notepad++ or Sublime Text or an actual Markdown editor like Typora. In that case, images will also be displayed within the editor. To open the note in an external editor, click on the icon in the toolbar or press Ctrl+E (or Cmd+E). Your default text editor will be used to open the note. If needed, you can also specify the editor directly in the General Options, under "Text editor command".
# Attachments / Resources
Any kind of file can be attached to a note. In Markdown, links to these files are represented as a simple ID to the resource. In the note viewer, these files, if they are images, will be displayed or, if they are other files (PDF, text files, etc.) they will be displayed as links. Clicking on this link will open the file in the default application.
On the **desktop application**, images can be attached either by clicking on "Attach file" or by pasting (with Ctrl+V) an image directly in the editor, or by drag and dropping an image.
Resources that are not attached to any note will be automatically deleted after 10 days (see [rationale](https://github.com/laurent22/joplin/issues/154#issuecomment-356582366)).
**Important:** Resources larger than 10 MB are not currently supported on mobile. They will crash the application when synchronising so it is recommended not to attach such resources at the moment. The issue is being looked at.
# Notifications
On the desktop and mobile apps, an alarm can be associated with any to-do. It will be triggered at the given time by displaying a notification. How the notification will be displayed depends on the operating system since each has a different way to handle this. Please see below for the requirements for the desktop applications:
- **Windows**: >= 8. Make sure the Action Center is enabled on Windows. Task bar balloon for Windows < 8. Growl as fallback. Growl takes precedence over Windows balloons.
- **macOS**: >= 10.8 or Growl if earlier.
- **Linux**: `notify-osd` or `libnotify-bin` installed (Ubuntu should have this by default). Growl otherwise
See [documentation and flow chart for reporter choice](https://github.com/mikaelbr/node-notifier/blob/master/DECISION_FLOW.md)
On mobile, the alarms will be displayed using the built-in notification system.
If for any reason the notifications do not work, please [open an issue](https://github.com/laurent22/joplin/issues).
# Sub-notebooks
Sub-notebooks allow organising multiple notebooks into a tree of notebooks. For example it can be used to regroup all the notebooks related to work, to family or to a particular project under a parent notebook.
![](https://joplinapp.org/images/SubNotebooks.png)
- On the **desktop application**, to create a subnotebook, drag and drop it onto another notebook. To move it back to the root, drag and drop it on the "Notebooks" header. Currently only the desktop app can be used to organise the notebooks.
- The **mobile application** supports displaying and collapsing/expanding the tree of notebooks, however it does not currently support moving the subnotebooks to different notebooks.
- The **terminal app** supports displaying the tree of subnotebooks but it does not support collapsing/expanding them or moving the subnotebooks around.
# Markdown
Joplin uses and renders [Github-flavoured Markdown](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) with a few variations and additions. In particular:
## Links to other notes
You can create a link to a note by specifying its ID in the URL. For example:
[Link to my note](:/0b0d62d15e60409dac34f354b6e9e839)
Since getting the ID of a note is not straightforward, each app provides a way to create such link. In the **desktop app**, right click on a note an select "Copy Markdown link". In the **mobile app**, open a note and, in the top right menu, select "Copy Markdown link". You can then paste this link anywhere in another note.
## Plugins
Joplin supports a number of plugins that can be toggled on top the standard markdown features you would expect. These toggle-able plugins are listed below. Note: not all of the plugins are enabled by default, if the enable field is 'no' below, then enter Tools->General Options to enable the plugin. Plugins can be disabled in the same manner.
| Plugin | Syntax | Description | Enabled |
|--------|--------|-------------|---------|
| [Katex](https://katex.org) | `$$math expr$$` or `$math$` | [See Below](https://github.com/laurent22/joplin#math-notation) | yes |
| [Mark](https://github.com/markdown-it/markdown-it-mark) | `==marked==` | Transforms into `<mark>marked</mark>` (highlighted) | yes |
| [Footnote](https://github.com/markdown-it/markdown-it-footnote) | `Simples inline footnote ^[I'm inline!]` | See [plugin page](https://github.com/markdown-it/markdown-it-footnote) for full description | yes |
| [TOC](https://github.com/nagaozen/markdown-it-toc-done-right) | Any of `${toc}, [[toc]], [toc], [[_toc_]]` | Adds a table of contents to the location of the toc page. Based on headings and sub-headings | no |
| [Sub](https://github.com/markdown-it/markdown-it-sub) | `X~1~` | Transforms into X<sub>1</sub> | no |
| [Sup](https://github.com/markdown-it/markdown-it-sup) | `X^2^` | Transforms into X<sup>2</sup> | no |
| [Deflist](https://github.com/markdown-it/markdown-it-deflist) | See [pandoc](http://johnmacfarlane.net/pandoc/README.html#definition-lists) page for syntax | Adds the html `<dl>` tag accessible through markdown | no |
| [Abbr](https://github.com/markdown-it/markdown-it-abbr) | *[HTML]: Hyper Text Markup Language | Allows definition of abbreviations that can be hovered over later for a full expansion | no |
| [Emoji](https://github.com/markdown-it/markdown-it-emoji) | `:smile:` :smile: | See [this list](https://gist.github.com/rxaviers/7360908) for more emoji | no |
| [Insert](https://github.com/markdown-it/markdown-it-ins) | `++inserted++` | Transforms into `<ins>inserted</ins>` (<ins>inserted</ins>) | no |
| [Multitable](https://github.com/RedBug312/markdown-it-multimd-table) | See [MultiMarkdown](https://fletcher.github.io/MultiMarkdown-6/syntax/tables.html) page | Adds more power and customization to markdown tables | no |
## Math notation
Math expressions can be added using the [KaTeX notation](https://khan.github.io/KaTeX/). To add an inline equation, wrap the expression in `$EXPRESSION$`, eg. `$\sqrt{3x-1}+(1+x)^2$`. To create an expression block, wrap it as follow:
$$
EXPRESSION
$$
For example:
$$
f(x) = \int_{-\infty}^\infty
\hat f(\xi)\,e^{2 \pi i \xi x}
\,d\xi
$$
Here is an example with the Markdown and rendered result side by side:
<img src="https://joplinapp.org/images/Katex.png" height="400px">
## Checkboxes
Checkboxes can be added like so:
- [ ] Milk
- [ ] Rice
- [ ] Eggs
The checkboxes can then be ticked in the mobile and desktop applications.
## HTML support
It is generally recommended to enter the notes as Markdown as it makes the notes easier to edit. However for cases where certain features aren't supported (such as strikethrough or to highlight text), you can also use HTML code directly. For example this would be a valid note:
This is <s>strikethrough text</s> mixed with regular **Markdown**.
## Custom CSS
Rendered markdown can be customized by placing a userstyle file in the profile directory `~/.config/joplin-desktop/userstyle.css` (This path might be different on your device - check at the top of the Config screen for the exact path). This file supports standard CSS syntax. Note that this file is used only when display the notes, **not when printing or exporting to PDF**. This is because printing has a lot more restrictions (for example, printing white text over a black background is usually not wanted), so special rules are applied to make it look good when printing, and a userstyle.css would interfer with that.
# Searching
Joplin implements the SQLite Full Text Search (FTS4) extension. It means the content of all the notes is indexed in real time and search queries return results very fast. Both [Simple FTS Queries](https://www.sqlite.org/fts3.html#simple_fts_queries) and [Full-Text Index Queries](https://www.sqlite.org/fts3.html#full_text_index_queries) are supported. See below for the list of supported queries:
Search type | Description | Example
------------|-------------|---------
Single word | Returns all the notes that contain this term. | `dog`, `cat`
Multiples words | Returns all the notes that contain **all** these words, but not necessarily next to each other. | `dog cat` - will return any notes that contain the words "dog" and "cat" anywhere in the note, no necessarily in that order nor next to each others. It will **not** return results that contain "dog" or "cat" only.
Phrase query | Add double quotes to return the notes that contain exactly this phrase. | `"shopping list"` - will return the notes that contain these **exact terms** next to each others and in this order. It will **not** return for example a note that contain "going shopping with my list".
Prefix | Add a wildmark to return all the notes that contain a term with a specified prefix. | `swim*` - will return all the notes that contain eg. "swim", but also "swimming", "swimsuit", etc. IMPORTANT: The wildcard **can only be at the end** - it will be ignored at the beginning of a word (eg. `*swim`) and will be treated as a literal asterisk in the middle of a word (eg. `ast*rix`)
Field restricted | Add either `title:` or `body:` before a note to restrict your search to just the title, or just the body. | `title:shopping`, `body:egg`
Notes are sorted by "relevance". Currently it means the notes that contain the requested terms the most times are on top. For queries with multiple terms, it also matter how close to each others are the terms. This is a bit experimental so if you notice a search query that returns unexpected results, please report it in the forum, providing as much details as possible to replicate the issue.
# Donations
Donations to Joplin support the development of the project. Developing quality applications mostly takes time, but there are also some expenses, such as digital certificates to sign the applications, app store fees, hosting, etc. Most of all, your donation will make it possible to keep up the current development standard.
Please see the [donation page](https://joplinapp.org/donate/) for information on how to support the development of Joplin.
# Community
- For general discussion about Joplin, user support, software development questions, and to discuss new features, go to the [Joplin Forum](https://discourse.joplin.cozic.net/). It is possible to login with your GitHub account.
- Also see here for information about [the latest releases and general news](https://discourse.joplin.cozic.net/c/news).
- For bug reports and feature requests, go to the [GitHub Issue Tracker](https://github.com/laurent22/joplin/issues).
- The latest news are posted [on the Patreon page](https://www.patreon.com/joplin).
# Contributing
Please see the guide for information on how to contribute to the development of Joplin: https://github.com/laurent22/joplin/blob/master/CONTRIBUTING.md
# Localisation
Joplin is currently available in the languages below. If you would like to contribute a **new translation**, it is quite straightforward, please follow these steps:
- [Download Poedit](https://poedit.net/), the translation editor, and install it.
- [Download the file to be translated](https://raw.githubusercontent.com/laurent22/joplin/master/CliClient/locales/joplin.pot).
- In Poedit, open this .pot file, go into the Catalog menu and click Configuration. Change "Country" and "Language" to your own country and language.
- From then you can translate the file. Once it is done, please either [open a pull request](https://github.com/laurent22/joplin/pulls) or send the file to [this address](https://raw.githubusercontent.com/laurent22/joplin/master/Assets/Adresse.png).
This translation will apply to the three applications - desktop, mobile and terminal.
To **update a translation**, follow the same steps as above but instead of getting the .pot file, get the .po file for your language from the table below.
Current translations:
<!-- LOCALE-TABLE-AUTO-GENERATED -->
&nbsp; | Language | Po File | Last translator | Percent done
---|---|---|---|---
![](https://joplinapp.org/images/flags/country-4x3/arableague.png) | Arabic | [ar](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ar.po) | عبد الناصر سعيد (as@althobaity.com) | 95%
![](https://joplinapp.org/images/flags/es/basque_country.png) | Basque | [eu](https://github.com/laurent22/joplin/blob/master/CliClient/locales/eu.po) | juan.abasolo@ehu.eus | 54%
![](https://joplinapp.org/images/flags/es/catalonia.png) | Catalan | [ca](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ca.po) | jmontane, 2018 | 78%
![](https://joplinapp.org/images/flags/country-4x3/hr.png) | Croatian | [hr_HR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po) | Hrvoje Mandić (trbuhom@net.hr) | 44%
![](https://joplinapp.org/images/flags/country-4x3/cz.png) | Czech | [cs_CZ](https://github.com/laurent22/joplin/blob/master/CliClient/locales/cs_CZ.po) | Lukas Helebrandt (lukas@aiya.cz) | 69%
![](https://joplinapp.org/images/flags/country-4x3/dk.png) | Dansk | [da_DK](https://github.com/laurent22/joplin/blob/master/CliClient/locales/da_DK.po) | Morten Juhl-Johansen Zölde-Fejér (mjjzf@syntaktisk. | 70%
![](https://joplinapp.org/images/flags/country-4x3/de.png) | Deutsch | [de_DE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po) | Michael Sonntag (ms@editorei.de) | 100%
![](https://joplinapp.org/images/flags/country-4x3/gb.png) | English (UK) | [en_GB](https://github.com/laurent22/joplin/blob/master/CliClient/locales/en_GB.po) | | 100%
![](https://joplinapp.org/images/flags/country-4x3/us.png) | English (US) | [en_US](https://github.com/laurent22/joplin/blob/master/CliClient/locales/en_US.po) | | 100%
![](https://joplinapp.org/images/flags/country-4x3/es.png) | Español | [es_ES](https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po) | Andros Fenollosa (andros@fenollosa.email) | 96%
![](https://joplinapp.org/images/flags/country-4x3/fr.png) | Français | [fr_FR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po) | Laurent Cozic | 100%
![](https://joplinapp.org/images/flags/es/galicia.png) | Galician | [gl_ES](https://github.com/laurent22/joplin/blob/master/CliClient/locales/gl_ES.po) | Marcos Lans (marcoslansgarza@gmail.com) | 69%
![](https://joplinapp.org/images/flags/country-4x3/it.png) | Italiano | [it_IT](https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po) | | 86%
![](https://joplinapp.org/images/flags/country-4x3/be.png) | Nederlands | [nl_BE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po) | | 55%
![](https://joplinapp.org/images/flags/country-4x3/nl.png) | Nederlands | [nl_NL](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_NL.po) | Heimen Stoffels (vistausss@outlook.com) | 83%
![](https://joplinapp.org/images/flags/country-4x3/no.png) | Norwegian | [nb_NO](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nb_NO.po) | Mats Estensen (code@mxe.no) | 96%
![](https://joplinapp.org/images/flags/country-4x3/br.png) | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po) | Renato Nunes Bastos (rnbastos@gmail.com) | 90%
![](https://joplinapp.org/images/flags/country-4x3/ro.png) | Română | [ro](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ro.po) | | 54%
![](https://joplinapp.org/images/flags/country-4x3/si.png) | Slovenian | [sl_SI](https://github.com/laurent22/joplin/blob/master/CliClient/locales/sl_SI.po) | | 68%
![](https://joplinapp.org/images/flags/country-4x3/se.png) | Svenska | [sv](https://github.com/laurent22/joplin/blob/master/CliClient/locales/sv.po) | Jonatan Nyberg (jonatan@autistici.org) | 93%
![](https://joplinapp.org/images/flags/country-4x3/tr.png) | Türkçe | [tr_TR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/tr_TR.po) | Zorbey Doğangüneş (zorbeyd@gmail.com) | 90%
![](https://joplinapp.org/images/flags/country-4x3/ru.png) | Русский | [ru_RU](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po) | Artyom Karlov (artyom.karlov@gmail.com) | 96%
![](https://joplinapp.org/images/flags/country-4x3/cn.png) | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/master/CliClient/locales/zh_CN.po) | | 96%
![](https://joplinapp.org/images/flags/country-4x3/tw.png) | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/master/CliClient/locales/zh_TW.po) | penguinsam (samliu@gmail.com) | 83%
![](https://joplinapp.org/images/flags/country-4x3/jp.png) | 日本語 | [ja_JP](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ja_JP.po) | AWASHIRO Ikuya (ikunya@gmail.com) | 90%
![](https://joplinapp.org/images/flags/country-4x3/kr.png) | 한국말 | [ko](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ko.po) | | 92%
<!-- LOCALE-TABLE-AUTO-GENERATED -->
# Known bugs
- Resources larger than 10 MB are not currently supported on mobile. They will crash the application so it is recommended not to attach such resources at the moment. The issue is being looked at.
- 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.
- 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
MIT License
Copyright (c) 2016-2019 Laurent Cozic
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,97 +0,0 @@
const { _ } = require('lib/locale.js');
const { Logger } = require('lib/logger.js');
const Resource = require('lib/models/Resource.js');
const { netUtils } = require('lib/net-utils.js');
const http = require("http");
const urlParser = require("url");
const enableServerDestroy = require('server-destroy');
class ResourceServer {
constructor() {
this.server_ = null;
this.logger_ = new Logger();
this.port_ = null;
this.linkHandler_ = null;
this.started_ = false;
}
setLogger(logger) {
this.logger_ = logger;
}
logger() {
return this.logger_;
}
started() {
return this.started_;
}
baseUrl() {
if (!this.port_) return '';
return 'http://127.0.0.1:' + this.port_;
}
setLinkHandler(handler) {
this.linkHandler_ = handler;
}
async start() {
this.port_ = await netUtils.findAvailablePort([9167, 9267, 8167, 8267]);
if (!this.port_) {
this.logger().error('Could not find available port to start resource server. Please report the error at https://github.com/laurent22/joplin');
return;
}
this.server_ = http.createServer();
this.server_.on('request', async (request, response) => {
const writeResponse = (message) => {
response.write(message);
response.end();
}
const url = urlParser.parse(request.url, true);
let resourceId = url.pathname.split('/');
if (resourceId.length < 2) {
writeResponse('Error: could not get resource ID from path name: ' + url.pathname);
return;
}
resourceId = resourceId[1];
if (!this.linkHandler_) throw new Error('No link handler is defined');
try {
const done = await this.linkHandler_(resourceId, response);
if (!done) throw new Error('Unhandled resource: ' + resourceId);
} catch (error) {
response.setHeader('Content-Type', 'text/plain');
response.statusCode = 400;
response.write(error.message);
}
response.end();
});
this.server_.on('error', (error) => {
this.logger().error('Resource server:', error);
});
this.server_.listen(this.port_);
enableServerDestroy(this.server_);
this.started_ = true;
}
stop() {
if (this.server_) this.server_.destroy();
this.server_ = null;
}
}
module.exports = ResourceServer;

View File

@ -1,822 +0,0 @@
const { Logger } = require('lib/logger.js');
const Folder = require('lib/models/Folder.js');
const BaseItem = require('lib/models/BaseItem.js');
const Tag = require('lib/models/Tag.js');
const BaseModel = require('lib/BaseModel.js');
const Note = require('lib/models/Note.js');
const Resource = require('lib/models/Resource.js');
const { cliUtils } = require('./cli-utils.js');
const { reducer, defaultState } = require('lib/reducer.js');
const { splitCommandString } = require('lib/string-utils.js');
const { reg } = require('lib/registry.js');
const { _ } = require('lib/locale.js');
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode;
const chalk = require('chalk');
const tk = require('terminal-kit');
const TermWrapper = require('tkwidgets/framework/TermWrapper.js');
const Renderer = require('tkwidgets/framework/Renderer.js');
const DecryptionWorker = require('lib/services/DecryptionWorker');
const BaseWidget = require('tkwidgets/BaseWidget.js');
const ListWidget = require('tkwidgets/ListWidget.js');
const TextWidget = require('tkwidgets/TextWidget.js');
const HLayoutWidget = require('tkwidgets/HLayoutWidget.js');
const VLayoutWidget = require('tkwidgets/VLayoutWidget.js');
const ReduxRootWidget = require('tkwidgets/ReduxRootWidget.js');
const RootWidget = require('tkwidgets/RootWidget.js');
const WindowWidget = require('tkwidgets/WindowWidget.js');
const NoteWidget = require('./gui/NoteWidget.js');
const ResourceServer = require('./ResourceServer.js');
const NoteMetadataWidget = require('./gui/NoteMetadataWidget.js');
const FolderListWidget = require('./gui/FolderListWidget.js');
const NoteListWidget = require('./gui/NoteListWidget.js');
const StatusBarWidget = require('./gui/StatusBarWidget.js');
const ConsoleWidget = require('./gui/ConsoleWidget.js');
class AppGui {
constructor(app, store, keymap) {
try {
this.app_ = app;
this.store_ = store;
BaseWidget.setLogger(app.logger());
this.term_ = new TermWrapper(tk.terminal);
// Some keys are directly handled by the tkwidget framework
// so they need to be remapped in a different way.
this.tkWidgetKeys_ = {
'focus_next': 'TAB',
'focus_previous': 'SHIFT_TAB',
'move_up': 'UP',
'move_down': 'DOWN',
'page_down': 'PAGE_DOWN',
'page_up': 'PAGE_UP',
};
this.renderer_ = null;
this.logger_ = new Logger();
this.buildUi();
this.renderer_ = new Renderer(this.term(), this.rootWidget_);
this.app_.on('modelAction', async (event) => {
await this.handleModelAction(event.action);
});
this.keymap_ = this.setupKeymap(keymap);
this.inputMode_ = AppGui.INPUT_MODE_NORMAL;
this.commandCancelCalled_ = false;
this.currentShortcutKeys_ = [];
this.lastShortcutKeyTime_ = 0;
// Recurrent sync is setup only when the GUI is started. In
// a regular command it's not necessary since the process
// exits right away.
reg.setupRecurrentSync();
DecryptionWorker.instance().scheduleStart();
} catch (error) {
this.fullScreen(false);
console.error(error);
process.exit(1);
}
}
store() {
return this.store_;
}
renderer() {
return this.renderer_;
}
async forceRender() {
this.widget('root').invalidate();
await this.renderer_.renderRoot();
}
termSaveState() {
return this.term().saveState();
}
termRestoreState(state) {
return this.term().restoreState(state);
}
prompt(initialText = '', promptString = ':', options = null) {
return this.widget('statusBar').prompt(initialText, promptString, options);
}
stdoutMaxWidth() {
return this.widget('console').innerWidth - 1;
}
isDummy() {
return false;
}
buildUi() {
this.rootWidget_ = new ReduxRootWidget(this.store_);
this.rootWidget_.name = 'root';
this.rootWidget_.autoShortcutsEnabled = false;
const folderList = new FolderListWidget();
folderList.style = {
borderBottomWidth: 1,
borderRightWidth: 1,
};
folderList.name = 'folderList';
folderList.vStretch = true;
folderList.on('currentItemChange', async (event) => {
const item = folderList.currentItem;
if (item === '-') {
let newIndex = event.currentIndex + (event.previousIndex < event.currentIndex ? +1 : -1);
let nextItem = folderList.itemAt(newIndex);
if (!nextItem) nextItem = folderList.itemAt(event.previousIndex);
if (!nextItem) return; // Normally not possible
let actionType = 'FOLDER_SELECT';
if (nextItem.type_ === BaseModel.TYPE_TAG) actionType = 'TAG_SELECT';
if (nextItem.type_ === BaseModel.TYPE_SEARCH) actionType = 'SEARCH_SELECT';
this.store_.dispatch({
type: actionType,
id: nextItem.id,
});
} else if (item.type_ === Folder.modelType()) {
this.store_.dispatch({
type: 'FOLDER_SELECT',
id: item ? item.id : null,
});
} else if (item.type_ === Tag.modelType()) {
this.store_.dispatch({
type: 'TAG_SELECT',
id: item ? item.id : null,
});
} else if (item.type_ === BaseModel.TYPE_SEARCH) {
this.store_.dispatch({
type: 'SEARCH_SELECT',
id: item ? item.id : null,
});
}
});
this.rootWidget_.connect(folderList, (state) => {
return {
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
selectedSearchId: state.selectedSearchId,
notesParentType: state.notesParentType,
folders: state.folders,
tags: state.tags,
searches: state.searches,
};
});
const noteList = new NoteListWidget();
noteList.name = 'noteList';
noteList.vStretch = true;
noteList.style = {
borderBottomWidth: 1,
borderLeftWidth: 1,
borderRightWidth: 1,
};
noteList.on('currentItemChange', async () => {
let note = noteList.currentItem;
this.store_.dispatch({
type: 'NOTE_SELECT',
id: note ? note.id : null,
});
});
this.rootWidget_.connect(noteList, (state) => {
return {
selectedNoteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
items: state.notes,
};
});
const noteText = new NoteWidget();
noteText.hStretch = true;
noteText.name = 'noteText';
noteText.style = {
borderBottomWidth: 1,
borderLeftWidth: 1,
};
this.rootWidget_.connect(noteText, (state) => {
return {
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
notes: state.notes,
};
});
const noteMetadata = new NoteMetadataWidget();
noteMetadata.hStretch = true;
noteMetadata.name = 'noteMetadata';
noteMetadata.style = {
borderBottomWidth: 1,
borderLeftWidth: 1,
borderRightWidth: 1,
};
this.rootWidget_.connect(noteMetadata, (state) => {
return { noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null };
});
noteMetadata.hide();
const consoleWidget = new ConsoleWidget();
consoleWidget.hStretch = true;
consoleWidget.style = {
borderBottomWidth: 1,
};
consoleWidget.hide();
const statusBar = new StatusBarWidget();
statusBar.hStretch = true;
const noteLayout = new VLayoutWidget();
noteLayout.name = 'noteLayout';
noteLayout.addChild(noteText, { type: 'stretch', factor: 1 });
noteLayout.addChild(noteMetadata, { type: 'stretch', factor: 1 });
const hLayout = new HLayoutWidget();
hLayout.name = 'hLayout';
hLayout.addChild(folderList, { type: 'stretch', factor: 1 });
hLayout.addChild(noteList, { type: 'stretch', factor: 1 });
hLayout.addChild(noteLayout, { type: 'stretch', factor: 2 });
const vLayout = new VLayoutWidget();
vLayout.name = 'vLayout';
vLayout.addChild(hLayout, { type: 'stretch', factor: 2 });
vLayout.addChild(consoleWidget, { type: 'stretch', factor: 1 });
vLayout.addChild(statusBar, { type: 'fixed', factor: 1 });
const win1 = new WindowWidget();
win1.addChild(vLayout);
win1.name = 'mainWindow';
this.rootWidget_.addChild(win1);
}
showModalOverlay(text) {
if (!this.widget('overlayWindow')) {
const textWidget = new TextWidget();
textWidget.hStretch = true;
textWidget.vStretch = true;
textWidget.text = 'testing';
textWidget.name = 'overlayText';
const win = new WindowWidget();
win.name = 'overlayWindow';
win.addChild(textWidget);
this.rootWidget_.addChild(win);
}
this.widget('overlayWindow').activate();
this.widget('overlayText').text = text;
}
hideModalOverlay() {
if (this.widget('overlayWindow')) this.widget('overlayWindow').hide();
this.widget('mainWindow').activate();
}
addCommandToConsole(cmd) {
if (!cmd) return;
const isConfigPassword = cmd.indexOf('config ') >= 0 && cmd.indexOf('password') >= 0;
if (isConfigPassword) return;
this.stdout(chalk.cyan.bold('> ' + cmd));
}
setupKeymap(keymap) {
const output = [];
for (let i = 0; i < keymap.length; i++) {
const item = Object.assign({}, keymap[i]);
if (!item.command) throw new Error('Missing command for keymap item: ' + JSON.stringify(item));
if (!('type' in item)) item.type = 'exec';
if (item.command in this.tkWidgetKeys_) {
item.type = 'tkwidgets';
}
item.canRunAlongOtherCommands = item.type === 'function' && ['toggle_metadata', 'toggle_console'].indexOf(item.command) >= 0;
output.push(item);
}
return output;
}
toggleConsole() {
this.showConsole(!this.consoleIsShown());
}
showConsole(doShow = true) {
this.widget('console').show(doShow);
}
hideConsole() {
this.showConsole(false);
}
consoleIsShown() {
return this.widget('console').shown;
}
maximizeConsole(doMaximize = true) {
const consoleWidget = this.widget('console');
if (consoleWidget.isMaximized__ === undefined) {
consoleWidget.isMaximized__ = false;
}
if (consoleWidget.isMaximized__ === doMaximize) return;
let constraints = {
type: 'stretch',
factor: !doMaximize ? 1 : 4,
};
consoleWidget.isMaximized__ = doMaximize;
this.widget('vLayout').setWidgetConstraints(consoleWidget, constraints);
}
minimizeConsole() {
this.maximizeConsole(false);
}
consoleIsMaximized() {
return this.widget('console').isMaximized__ === true;
}
showNoteMetadata(show = true) {
this.widget('noteMetadata').show(show);
}
hideNoteMetadata() {
this.showNoteMetadata(false);
}
toggleNoteMetadata() {
this.showNoteMetadata(!this.widget('noteMetadata').shown);
}
widget(name) {
if (name === 'root') return this.rootWidget_;
return this.rootWidget_.childByName(name);
}
app() {
return this.app_;
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
}
keymap() {
return this.keymap_;
}
keymapItemByKey(key) {
for (let i = 0; i < this.keymap_.length; i++) {
const item = this.keymap_[i];
if (item.keys.indexOf(key) >= 0) return item;
}
return null;
}
term() {
return this.term_;
}
activeListItem() {
const widget = this.widget('mainWindow').focusedWidget;
if (!widget) return null;
if (widget.name == 'noteList' || widget.name == 'folderList') {
return widget.currentItem;
}
return null;
}
async handleModelAction(action) {
this.logger().info('Action:', action);
let state = Object.assign({}, defaultState);
state.notes = this.widget('noteList').items;
let newState = reducer(state, action);
if (newState !== state) {
this.widget('noteList').items = newState.notes;
}
}
async processFunctionCommand(cmd) {
if (cmd === 'activate') {
const w = this.widget('mainWindow').focusedWidget;
if (w.name === 'folderList') {
this.widget('noteList').focus();
} else if (w.name === 'noteList' || w.name === 'noteText') {
this.processPromptCommand('edit $n');
}
} else if (cmd === 'delete') {
if (this.widget('folderList').hasFocus) {
const item = this.widget('folderList').selectedJoplinItem;
if (!item) return;
if (item.type_ === BaseModel.TYPE_FOLDER) {
await this.processPromptCommand('rmbook ' + item.id);
} else if (item.type_ === BaseModel.TYPE_TAG) {
this.stdout(_('To delete a tag, untag the associated notes.'));
} else if (item.type_ === BaseModel.TYPE_SEARCH) {
this.store().dispatch({
type: 'SEARCH_DELETE',
id: item.id,
});
}
} else if (this.widget('noteList').hasFocus) {
await this.processPromptCommand('rmnote $n');
} else {
this.stdout(_('Please select the note or notebook to be deleted first.'));
}
} else if (cmd === 'toggle_console') {
if (!this.consoleIsShown()) {
this.showConsole();
this.minimizeConsole();
} else {
if (this.consoleIsMaximized()) {
this.hideConsole();
} else {
this.maximizeConsole();
}
}
} else if (cmd === 'toggle_metadata') {
this.toggleNoteMetadata();
} else if (cmd === 'enter_command_line_mode') {
const cmd = await this.widget('statusBar').prompt();
if (!cmd) return;
this.addCommandToConsole(cmd);
await this.processPromptCommand(cmd);
} else {
throw new Error('Unknown command: ' + cmd);
}
}
async processPromptCommand(cmd) {
if (!cmd) return;
cmd = cmd.trim();
if (!cmd.length) return;
// this.logger().debug('Got command: ' + cmd);
try {
let note = this.widget('noteList').currentItem;
let folder = this.widget('folderList').currentItem;
let args = splitCommandString(cmd);
for (let i = 0; i < args.length; i++) {
if (args[i] == '$n') {
args[i] = note ? note.id : '';
} else if (args[i] == '$b') {
args[i] = folder ? folder.id : '';
} else if (args[i] == '$c') {
const item = this.activeListItem();
args[i] = item ? item.id : '';
}
}
await this.app().execCommand(args);
} catch (error) {
this.stdout(error.message);
}
this.widget('console').scrollBottom();
// Invalidate so that the screen is redrawn in case inputting a command has moved
// the GUI up (in particular due to autocompletion), it's moved back to the right position.
this.widget('root').invalidate();
}
async updateFolderList() {
const folders = await Folder.all();
this.widget('folderList').items = folders;
}
async updateNoteList(folderId) {
const fields = Note.previewFields();
fields.splice(fields.indexOf('body'), 1);
const notes = folderId ? await Note.previews(folderId, { fields: fields }) : [];
this.widget('noteList').items = notes;
}
async updateNoteText(note) {
const text = note ? note.body : '';
this.widget('noteText').text = text;
}
// Any key after which a shortcut is not possible.
isSpecialKey(name) {
return [':', 'ENTER', 'DOWN', 'UP', 'LEFT', 'RIGHT', 'DELETE', 'BACKSPACE', 'ESCAPE', 'TAB', 'SHIFT_TAB', 'PAGE_UP', 'PAGE_DOWN'].indexOf(name) >= 0;
}
fullScreen(enable = true) {
if (enable) {
this.term().fullscreen();
this.term().hideCursor();
this.widget('root').invalidate();
} else {
this.term().fullscreen(false);
this.term().showCursor();
}
}
stdout(text) {
if (text === null || text === undefined) return;
let lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const v = typeof lines[i] === 'object' ? JSON.stringify(lines[i]) : lines[i];
this.widget('console').addLine(v);
}
this.updateStatusBarMessage();
}
exit() {
this.fullScreen(false);
this.resourceServer_.stop();
}
updateStatusBarMessage() {
const consoleWidget = this.widget('console');
let msg = '';
const text = consoleWidget.lastLine;
const cmd = this.app().currentCommand();
if (cmd) {
msg += cmd.name();
if (cmd.cancellable()) msg += ' [Press Ctrl+C to cancel]';
msg += ': ';
}
if (text && text.length) {
msg += text;
}
if (msg !== '') this.widget('statusBar').setItemAt(0, msg);
}
async setupResourceServer() {
const linkStyle = chalk.blue.underline;
const noteTextWidget = this.widget('noteText');
const resourceIdRegex = /^:\/[a-f0-9]+$/i
const noteLinks = {};
const hasProtocol = function(s, protocols) {
if (!s) return false;
s = s.trim().toLowerCase();
for (let i = 0; i < protocols.length; i++) {
if (s.indexOf(protocols[i] + '://') === 0) return true;
}
return false;
}
// By default, before the server is started, only the regular
// URLs appear in blue.
noteTextWidget.markdownRendererOptions = {
linkUrlRenderer: (index, url) => {
if (!url) return url;
if (resourceIdRegex.test(url)) {
return url;
} else if (hasProtocol(url, ['http', 'https'])) {
return linkStyle(url);
} else {
return url;
}
},
};
this.resourceServer_ = new ResourceServer();
this.resourceServer_.setLogger(this.app().logger());
this.resourceServer_.setLinkHandler(async (path, response) => {
const link = noteLinks[path];
if (link.type === 'url') {
response.writeHead(302, { 'Location': link.url });
return true;
}
if (link.type === 'item') {
const itemId = link.id;
let item = await BaseItem.loadItemById(itemId);
if (!item) throw new Error('No item with ID ' + itemId); // Should be nearly impossible
if (item.type_ === BaseModel.TYPE_RESOURCE) {
if (item.mime) response.setHeader('Content-Type', item.mime);
response.write(await Resource.content(item));
} else if (item.type_ === BaseModel.TYPE_NOTE) {
const html = [`
<!DOCTYPE html>
<html class="client-nojs" lang="en" dir="ltr">
<head><meta charset="UTF-8"/></head><body>
`];
html.push('<pre>' + htmlentities(item.title) + '\n\n' + htmlentities(item.body) + '</pre>');
html.push('</body></html>');
response.write(html.join(''));
} else {
throw new Error('Unsupported item type: ' + item.type_);
}
return true;
}
return false;
});
await this.resourceServer_.start();
if (!this.resourceServer_.started()) return;
noteTextWidget.markdownRendererOptions = {
linkUrlRenderer: (index, url) => {
if (!url) return url;
if (resourceIdRegex.test(url)) {
noteLinks[index] = {
type: 'item',
id: url.substr(2),
};
} else if (hasProtocol(url, ['http', 'https', 'file', 'ftp'])) {
noteLinks[index] = {
type: 'url',
url: url,
};
} else if (url.indexOf('#') === 0) {
return ''; // Anchors aren't supported for now
} else {
return url;
}
return linkStyle(this.resourceServer_.baseUrl() + '/' + index);
},
};
}
async start() {
const term = this.term();
this.fullScreen();
try {
this.setupResourceServer();
this.renderer_.start();
const statusBar = this.widget('statusBar');
term.grabInput();
term.on('key', async (name, matches, data) => {
// -------------------------------------------------------------------------
// Handle special shortcuts
// -------------------------------------------------------------------------
if (name === 'CTRL_D') {
const cmd = this.app().currentCommand();
if (cmd && cmd.cancellable() && !this.commandCancelCalled_) {
this.commandCancelCalled_ = true;
await cmd.cancel();
this.commandCancelCalled_ = false;
}
await this.app().exit();
return;
}
if (name === 'CTRL_C' ) {
const cmd = this.app().currentCommand();
if (!cmd || !cmd.cancellable() || this.commandCancelCalled_) {
this.stdout(_('Press Ctrl+D or type "exit" to exit the application'));
} else {
this.commandCancelCalled_ = true;
await cmd.cancel()
this.commandCancelCalled_ = false;
}
return;
}
// -------------------------------------------------------------------------
// Build up current shortcut
// -------------------------------------------------------------------------
const now = (new Date()).getTime();
if (now - this.lastShortcutKeyTime_ > 800 || this.isSpecialKey(name)) {
this.currentShortcutKeys_ = [name];
} else {
// If the previous key was a special key (eg. up, down arrow), this new key
// starts a new shortcut.
if (this.currentShortcutKeys_.length && this.isSpecialKey(this.currentShortcutKeys_[0])) {
this.currentShortcutKeys_ = [name];
} else {
this.currentShortcutKeys_.push(name);
}
}
this.lastShortcutKeyTime_ = now;
// -------------------------------------------------------------------------
// Process shortcut and execute associated command
// -------------------------------------------------------------------------
const shortcutKey = this.currentShortcutKeys_.join('');
let keymapItem = this.keymapItemByKey(shortcutKey);
// If this command is an alias to another command, resolve to the actual command
let processShortcutKeys = !this.app().currentCommand() && keymapItem;
if (keymapItem && keymapItem.canRunAlongOtherCommands) processShortcutKeys = true;
if (statusBar.promptActive) processShortcutKeys = false;
if (processShortcutKeys) {
this.logger().debug('Shortcut:', shortcutKey, keymapItem);
this.currentShortcutKeys_ = [];
if (keymapItem.type === 'function') {
this.processFunctionCommand(keymapItem.command);
} else if (keymapItem.type === 'prompt') {
let promptOptions = {};
if ('cursorPosition' in keymapItem) promptOptions.cursorPosition = keymapItem.cursorPosition;
const commandString = await statusBar.prompt(keymapItem.command ? keymapItem.command : '', null, promptOptions);
this.addCommandToConsole(commandString);
await this.processPromptCommand(commandString);
} else if (keymapItem.type === 'exec') {
this.stdout(keymapItem.command);
await this.processPromptCommand(keymapItem.command);
} else if (keymapItem.type === 'tkwidgets') {
this.widget('root').handleKey(this.tkWidgetKeys_[keymapItem.command]);
} else {
throw new Error('Unknown command type: ' + JSON.stringify(keymapItem));
}
}
// Optimisation: Update the status bar only
// if the user is not already typing a command:
if (!statusBar.promptActive) this.updateStatusBarMessage();
});
} catch (error) {
this.fullScreen(false);
this.logger().error(error);
console.error(error);
}
process.on('unhandledRejection', (reason, p) => {
this.fullScreen(false);
console.error('Unhandled promise rejection', p, 'reason:', reason);
process.exit(1);
});
}
}
AppGui.INPUT_MODE_NORMAL = 1;
AppGui.INPUT_MODE_META = 2;
module.exports = AppGui;

View File

@ -1,451 +0,0 @@
const { BaseApplication } = require('lib/BaseApplication');
const { createStore, applyMiddleware } = require('redux');
const { reducer, defaultState } = require('lib/reducer.js');
const { JoplinDatabase } = require('lib/joplin-database.js');
const { Database } = require('lib/database.js');
const { FoldersScreenUtils } = require('lib/folders-screen-utils.js');
const { DatabaseDriverNode } = require('lib/database-driver-node.js');
const ResourceService = require('lib/services/ResourceService');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const BaseItem = require('lib/models/BaseItem.js');
const Note = require('lib/models/Note.js');
const Tag = require('lib/models/Tag.js');
const Setting = require('lib/models/Setting.js');
const { Logger } = require('lib/logger.js');
const { sprintf } = require('sprintf-js');
const { reg } = require('lib/registry.js');
const { fileExtension } = require('lib/path-utils.js');
const { shim } = require('lib/shim.js');
const { _, setLocale, defaultLocale, closestSupportedLocale } = require('lib/locale.js');
const os = require('os');
const fs = require('fs-extra');
const { cliUtils } = require('./cli-utils.js');
const Cache = require('lib/Cache');
const WelcomeUtils = require('lib/WelcomeUtils');
const RevisionService = require('lib/services/RevisionService');
class Application extends BaseApplication {
constructor() {
super();
this.showPromptString_ = true;
this.commands_ = {};
this.commandMetadata_ = null;
this.activeCommand_ = null;
this.allCommandsLoaded_ = false;
this.showStackTraces_ = false;
this.gui_ = null;
this.cache_ = new Cache();
}
gui() {
return this.gui_;
}
commandStdoutMaxWidth() {
return this.gui().stdoutMaxWidth();
}
async guessTypeAndLoadItem(pattern, options = null) {
let type = BaseModel.TYPE_NOTE;
if (pattern.indexOf('/') === 0) {
type = BaseModel.TYPE_FOLDER;
pattern = pattern.substr(1);
}
return this.loadItem(type, pattern, options);
}
async loadItem(type, pattern, options = null) {
let output = await this.loadItems(type, pattern, options);
if (output.length > 1) {
// output.sort((a, b) => { return a.user_updated_time < b.user_updated_time ? +1 : -1; });
// let answers = { 0: _('[Cancel]') };
// for (let i = 0; i < output.length; i++) {
// answers[i + 1] = output[i].title;
// }
// Not really useful with new UI?
throw new Error(_('More than one item match "%s". Please narrow down your query.', pattern));
// let msg = _('More than one item match "%s". Please select one:', pattern);
// const response = await cliUtils.promptMcq(msg, answers);
// if (!response) return null;
return output[response - 1];
} else {
return output.length ? output[0] : null;
}
}
async loadItems(type, pattern, options = null) {
if (type === 'folderOrNote') {
const folders = await this.loadItems(BaseModel.TYPE_FOLDER, pattern, options);
if (folders.length) return folders;
return await this.loadItems(BaseModel.TYPE_NOTE, pattern, options);
}
pattern = pattern ? pattern.toString() : '';
if (type == BaseModel.TYPE_FOLDER && (pattern == Folder.conflictFolderTitle() || pattern == Folder.conflictFolderId())) return [Folder.conflictFolder()];
if (!options) options = {};
const parent = options.parent ? options.parent : app().currentFolder();
const ItemClass = BaseItem.itemClass(type);
if (type == BaseModel.TYPE_NOTE && pattern.indexOf('*') >= 0) { // Handle it as pattern
if (!parent) throw new Error(_('No notebook selected.'));
return await Note.previews(parent.id, { titlePattern: pattern });
} else { // Single item
let item = null;
if (type == BaseModel.TYPE_NOTE) {
if (!parent) throw new Error(_('No notebook has been specified.'));
item = await ItemClass.loadFolderNoteByField(parent.id, 'title', pattern);
} else {
item = await ItemClass.loadByTitle(pattern);
}
if (item) return [item];
item = await ItemClass.load(pattern); // Load by id
if (item) return [item];
if (pattern.length >= 2) {
return await ItemClass.loadByPartialId(pattern);
}
}
return [];
}
stdout(text) {
return this.gui().stdout(text);
}
setupCommand(cmd) {
cmd.setStdout((text) => {
return this.stdout(text);
});
cmd.setDispatcher((action) => {
if (this.store()) {
return this.store().dispatch(action);
} else {
return (action) => {};
}
});
cmd.setPrompt(async (message, options) => {
if (!options) options = {};
if (!options.type) options.type = 'boolean';
if (!options.booleanAnswerDefault) options.booleanAnswerDefault = 'y';
if (!options.answers) options.answers = options.booleanAnswerDefault === 'y' ? [_('Y'), _('n')] : [_('N'), _('y')];
if (options.type == 'boolean') {
message += ' (' + options.answers.join('/') + ')';
}
let answer = await this.gui().prompt('', message + ' ', options);
if (options.type === 'boolean') {
if (answer === null) return false; // Pressed ESCAPE
if (!answer) answer = options.answers[0];
let positiveIndex = options.booleanAnswerDefault == 'y' ? 0 : 1;
return answer.toLowerCase() === options.answers[positiveIndex].toLowerCase();
} else {
return answer;
}
});
return cmd;
}
async exit(code = 0) {
const doExit = async () => {
this.gui().exit();
await super.exit(code);
};
// Give it a few seconds to cancel otherwise exit anyway
setTimeout(async () => {
await doExit();
}, 5000);
if (await reg.syncTarget().syncStarted()) {
this.stdout(_('Cancelling background synchronisation... Please wait.'));
const sync = await reg.syncTarget().synchronizer();
await sync.cancel();
}
await doExit();
}
commands(uiType = null) {
if (!this.allCommandsLoaded_) {
fs.readdirSync(__dirname).forEach((path) => {
if (path.indexOf('command-') !== 0) return;
const ext = fileExtension(path)
if (ext != 'js') return;
let CommandClass = require('./' + path);
let cmd = new CommandClass();
if (!cmd.enabled()) return;
cmd = this.setupCommand(cmd);
this.commands_[cmd.name()] = cmd;
});
this.allCommandsLoaded_ = true;
}
if (uiType !== null) {
let temp = [];
for (let n in this.commands_) {
if (!this.commands_.hasOwnProperty(n)) continue;
const c = this.commands_[n];
if (!c.supportsUi(uiType)) continue;
temp[n] = c;
}
return temp;
}
return this.commands_;
}
async commandNames() {
const metadata = await this.commandMetadata();
let output = [];
for (let n in metadata) {
if (!metadata.hasOwnProperty(n)) continue;
output.push(n);
}
return output;
}
async commandMetadata() {
if (this.commandMetadata_) return this.commandMetadata_;
let output = await this.cache_.getItem('metadata');
if (output) {
this.commandMetadata_ = output;
return Object.assign({}, this.commandMetadata_);
}
const commands = this.commands();
output = {};
for (let n in commands) {
if (!commands.hasOwnProperty(n)) continue;
const cmd = commands[n];
output[n] = cmd.metadata();
}
await this.cache_.setItem('metadata', output, 1000 * 60 * 60 * 24);
this.commandMetadata_ = output;
return Object.assign({}, this.commandMetadata_);
}
hasGui() {
return this.gui() && !this.gui().isDummy();
}
findCommandByName(name) {
if (this.commands_[name]) return this.commands_[name];
let CommandClass = null;
try {
CommandClass = require(__dirname + '/command-' + name + '.js');
} catch (error) {
if (error.message && error.message.indexOf('Cannot find module') >= 0) {
let e = new Error(_('No such command: %s', name));
e.type = 'notFound';
throw e;
} else {
throw error;
}
}
let cmd = new CommandClass();
cmd = this.setupCommand(cmd);
this.commands_[name] = cmd;
return this.commands_[name];
}
dummyGui() {
return {
isDummy: () => { return true; },
prompt: (initialText = '', promptString = '', options = null) => { return cliUtils.prompt(initialText, promptString, options); },
showConsole: () => {},
maximizeConsole: () => {},
stdout: (text) => { console.info(text); },
fullScreen: (b=true) => {},
exit: () => {},
showModalOverlay: (text) => {},
hideModalOverlay: () => {},
stdoutMaxWidth: () => { return 100; },
forceRender: () => {},
termSaveState: () => {},
termRestoreState: (state) => {},
};
}
async execCommand(argv) {
if (!argv.length) return this.execCommand(['help']);
// reg.logger().debug('execCommand()', argv);
const commandName = argv[0];
this.activeCommand_ = this.findCommandByName(commandName);
let outException = null;
try {
if (this.gui().isDummy() && !this.activeCommand_.supportsUi('cli')) throw new Error(_('The command "%s" is only available in GUI mode', this.activeCommand_.name()));
const cmdArgs = cliUtils.makeCommandArgs(this.activeCommand_, argv);
await this.activeCommand_.action(cmdArgs);
} catch (error) {
outException = error;
}
this.activeCommand_ = null;
if (outException) throw outException;
}
currentCommand() {
return this.activeCommand_;
}
async loadKeymaps() {
const defaultKeyMap = [
{ "keys": [":"], "type": "function", "command": "enter_command_line_mode" },
{ "keys": ["TAB"], "type": "function", "command": "focus_next" },
{ "keys": ["SHIFT_TAB"], "type": "function", "command": "focus_previous" },
{ "keys": ["UP"], "type": "function", "command": "move_up" },
{ "keys": ["DOWN"], "type": "function", "command": "move_down" },
{ "keys": ["PAGE_UP"], "type": "function", "command": "page_up" },
{ "keys": ["PAGE_DOWN"], "type": "function", "command": "page_down" },
{ "keys": ["ENTER"], "type": "function", "command": "activate" },
{ "keys": ["DELETE", "BACKSPACE"], "type": "function", "command": "delete" },
{ "keys": [" "], "command": "todo toggle $n" },
{ "keys": ["tc"], "type": "function", "command": "toggle_console" },
{ "keys": ["tm"], "type": "function", "command": "toggle_metadata" },
{ "keys": ["/"], "type": "prompt", "command": "search \"\"", "cursorPosition": -2 },
{ "keys": ["mn"], "type": "prompt", "command": "mknote \"\"", "cursorPosition": -2 },
{ "keys": ["mt"], "type": "prompt", "command": "mktodo \"\"", "cursorPosition": -2 },
{ "keys": ["mb"], "type": "prompt", "command": "mkbook \"\"", "cursorPosition": -2 },
{ "keys": ["yn"], "type": "prompt", "command": "cp $n \"\"", "cursorPosition": -2 },
{ "keys": ["dn"], "type": "prompt", "command": "mv $n \"\"", "cursorPosition": -2 }
];
// Filter the keymap item by command so that items in keymap.json can override
// the default ones.
const itemsByCommand = {};
for (let i = 0; i < defaultKeyMap.length; i++) {
itemsByCommand[defaultKeyMap[i].command] = defaultKeyMap[i]
}
const filePath = Setting.value('profileDir') + '/keymap.json';
if (await fs.pathExists(filePath)) {
try {
let configString = await fs.readFile(filePath, 'utf-8');
configString = configString.replace(/^\s*\/\/.*/, ''); // Strip off comments
const keymap = JSON.parse(configString);
for (let keymapIndex = 0; keymapIndex < keymap.length; keymapIndex++) {
const item = keymap[keymapIndex];
itemsByCommand[item.command] = item;
}
} catch (error) {
let msg = error.message ? error.message : '';
msg = 'Could not load keymap ' + filePath + '\n' + msg;
error.message = msg;
throw error;
}
}
const output = [];
for (let n in itemsByCommand) {
if (!itemsByCommand.hasOwnProperty(n)) continue;
output.push(itemsByCommand[n]);
}
return output;
}
async start(argv) {
argv = await super.start(argv);
cliUtils.setStdout((object) => {
return this.stdout(object);
});
await WelcomeUtils.install(this.dispatch.bind(this));
// If we have some arguments left at this point, it's a command
// so execute it.
if (argv.length) {
this.gui_ = this.dummyGui();
this.currentFolder_ = await Folder.load(Setting.value('activeFolderId'));
try {
await this.execCommand(argv);
} catch (error) {
if (this.showStackTraces_) {
console.error(error);
} else {
console.info(error.message);
}
process.exit(1);
}
await Setting.saveAll();
// Need to call exit() explicitely, otherwise Node wait for any timeout to complete
// https://stackoverflow.com/questions/18050095
process.exit(0);
} else { // Otherwise open the GUI
this.initRedux();
const keymap = await this.loadKeymaps();
const AppGui = require('./app-gui.js');
this.gui_ = new AppGui(this, this.store(), keymap);
this.gui_.setLogger(this.logger_);
await this.gui_.start();
// Since the settings need to be loaded before the store is created, it will never
// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be
// initialised. So we manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
await FoldersScreenUtils.refreshFolders();
const tags = await Tag.allWithNotes();
ResourceService.runInBackground();
RevisionService.instance().runInBackground();
this.dispatch({
type: 'TAG_UPDATE_ALL',
items: tags,
});
this.store().dispatch({
type: 'FOLDER_SELECT',
id: Setting.value('activeFolderId'),
});
}
}
}
let application_ = null;
function app() {
if (application_) return application_;
application_ = new Application();
return application_;
}
module.exports = { app };

View File

@ -1,199 +0,0 @@
var { app } = require('./app.js');
var Note = require('lib/models/Note.js');
var Folder = require('lib/models/Folder.js');
var Tag = require('lib/models/Tag.js');
var { cliUtils } = require('./cli-utils.js');
var yargParser = require('yargs-parser');
var fs = require('fs-extra');
async function handleAutocompletionPromise(line) {
// Auto-complete the command name
const names = await app().commandNames();
let words = getArguments(line);
//If there is only one word and it is not already a command name then you
//should look for commmands it could be
if (words.length == 1) {
if (names.indexOf(words[0]) === -1) {
let x = names.filter((n) => n.indexOf(words[0]) === 0);
if (x.length === 1) {
return x[0] + ' ';
}
return x.length > 0 ? x.map((a) => a + ' ') : line;
} else {
return line;
}
}
//There is more than one word and it is a command
const metadata = (await app().commandMetadata())[words[0]];
//If for some reason this command does not have any associated metadata
//just don't autocomplete. However, this should not happen.
if (metadata === undefined) {
return line;
}
//complete an option
let next = words.length > 1 ? words[words.length - 1] : '';
let l = [];
if (next[0] === '-') {
for (let i = 0; i<metadata.options.length; i++) {
const options = metadata.options[i][0].split(' ');
//if there are multiple options then they will be separated by comma and
//space. The comma should be removed
if (options[0][options[0].length - 1] === ',') {
options[0] = options[0].slice(0, -1);
}
if (words.includes(options[0]) || words.includes(options[1])) {
continue;
}
//First two elements are the flag and the third is the description
//Only autocomplete long
if (options.length > 1 && options[1].indexOf(next) === 0) {
l.push(options[1]);
} else if (options[0].indexOf(next) === 0) {
l.push(options[0]);
}
}
if (l.length === 0) {
return line;
}
let ret = l.map(a=>toCommandLine(a));
ret.prefix = toCommandLine(words.slice(0, -1)) + ' ';
return ret;
}
//Complete an argument
//Determine the number of positional arguments by counting the number of
//words that don't start with a - less one for the command name
const positionalArgs = words.filter((a)=>a.indexOf('-') !== 0).length - 1;
let cmdUsage = yargParser(metadata.usage)['_'];
cmdUsage.splice(0, 1);
if (cmdUsage.length >= positionalArgs) {
let argName = cmdUsage[positionalArgs - 1];
argName = cliUtils.parseCommandArg(argName).name;
const currentFolder = app().currentFolder();
if (argName == 'note' || argName == 'note-pattern') {
const notes = currentFolder ? await Note.previews(currentFolder.id, { titlePattern: next + '*' }) : [];
l.push(...notes.map((n) => n.title));
}
if (argName == 'notebook') {
const folders = await Folder.search({ titlePattern: next + '*' });
l.push(...folders.map((n) => n.title));
}
if (argName == 'item') {
const notes = currentFolder ? await Note.previews(currentFolder.id, { titlePattern: next + '*' }) : [];
const folders = await Folder.search({ titlePattern: next + '*' });
l.push(...notes.map((n) => n.title), folders.map((n) => n.title));
}
if (argName == 'tag') {
let tags = await Tag.search({ titlePattern: next + '*' });
l.push(...tags.map((n) => n.title));
}
if (argName == 'file') {
let files = await fs.readdir('.');
l.push(...files);
}
if (argName == 'tag-command') {
let c = filterList(['add', 'remove', 'list'], next);
l.push(...c);
}
if (argName == 'todo-command') {
let c = filterList(['toggle', 'clear'], next);
l.push(...c);
}
}
if (l.length === 1) {
return toCommandLine([...words.slice(0, -1), l[0]]);
} else if (l.length > 1) {
let ret = l.map(a=>toCommandLine(a));
ret.prefix = toCommandLine(words.slice(0, -1)) + ' ';
return ret;
}
return line;
}
function handleAutocompletion(str, callback) {
handleAutocompletionPromise(str).then(function(res) {
callback(undefined, res);
});
}
function toCommandLine(args) {
if (Array.isArray(args)) {
return args.map(function(a) {
if(a.indexOf('"') !== -1 || a.indexOf(' ') !== -1) {
return "'" + a + "'";
} else if (a.indexOf("'") !== -1) {
return '"' + a + '"';
} else {
return a;
}
}).join(' ');
} else {
if(args.indexOf('"') !== -1 || args.indexOf(' ') !== -1) {
return "'" + args + "' ";
} else if (args.indexOf("'") !== -1) {
return '"' + args + '" ';
} else {
return args + ' ';
}
}
}
function getArguments(line) {
let inSingleQuotes = false;
let inDoubleQuotes = false;
let currentWord = '';
let parsed = [];
for(let i = 0; i<line.length; i++) {
if(line[i] === '"') {
if(inDoubleQuotes) {
inDoubleQuotes = false;
//maybe push word to parsed?
//currentWord += '"';
} else {
inDoubleQuotes = true;
//currentWord += '"';
}
} else if(line[i] === "'") {
if(inSingleQuotes) {
inSingleQuotes = false;
//maybe push word to parsed?
//currentWord += "'";
} else {
inSingleQuotes = true;
//currentWord += "'";
}
} else if (/\s/.test(line[i]) &&
!(inDoubleQuotes || inSingleQuotes)) {
if (currentWord !== '') {
parsed.push(currentWord);
currentWord = '';
}
} else {
currentWord += line[i];
}
}
if (!(inSingleQuotes || inDoubleQuotes) && /\s/.test(line[line.length - 1])) {
parsed.push('');
} else {
parsed.push(currentWord);
}
return parsed;
}
function filterList(list, next) {
let output = [];
for (let i = 0; i < list.length; i++) {
if (list[i].indexOf(next) !== 0) continue;
output.push(list[i]);
}
return output;
}
module.exports = { handleAutocompletion };

View File

@ -1,99 +0,0 @@
const { _ } = require('lib/locale.js');
const { reg } = require('lib/registry.js');
class BaseCommand {
constructor() {
this.stdout_ = null;
this.prompt_ = null;
}
usage() {
throw new Error('Usage not defined');
}
encryptionCheck(item) {
if (item && item.encryption_applied) throw new Error(_('Cannot change encrypted item'));
}
description() {
throw new Error('Description not defined');
}
async action(args) {
throw new Error('Action not defined');
}
compatibleUis() {
return ['cli', 'gui'];
}
supportsUi(ui) {
return this.compatibleUis().indexOf(ui) >= 0;
}
options() {
return [];
}
hidden() {
return false;
}
enabled() {
return true;
}
cancellable() {
return false;
}
async cancel() {}
name() {
let r = this.usage().split(' ');
return r[0];
}
setDispatcher(fn) {
this.dispatcher_ = fn;
}
dispatch(action) {
if (!this.dispatcher_) throw new Error('Dispatcher not defined');
return this.dispatcher_(action);
}
setStdout(fn) {
this.stdout_ = fn;
}
stdout(text) {
if (this.stdout_) this.stdout_(text);
}
setPrompt(fn) {
this.prompt_ = fn;
}
async prompt(message, options = null) {
if (!this.prompt_) throw new Error('Prompt is undefined');
return await this.prompt_(message, options);
}
metadata() {
return {
name: this.name(),
usage: this.usage(),
options: this.options(),
hidden: this.hidden(),
};
}
logger() {
return reg.logger();
}
}
module.exports = { BaseCommand };

View File

@ -1,139 +0,0 @@
const fs = require('fs-extra');
const { fileExtension, basename, dirname } = require('lib/path-utils.js');
const wrap_ = require('word-wrap');
const { _, setLocale, languageCode } = require('lib/locale.js');
const rootDir = dirname(dirname(__dirname));
const MAX_WIDTH = 78;
const INDENT = ' ';
function wrap(text, indent) {
return wrap_(text, {
width: MAX_WIDTH - indent.length,
indent: indent,
});
}
function renderOptions(options) {
let output = [];
const optionColWidth = getOptionColWidth(options);
for (let i = 0; i < options.length; i++) {
let option = options[i];
const flag = option[0];
const indent = INDENT + INDENT + ' '.repeat(optionColWidth + 2);
let r = wrap(option[1], indent);
r = r.substr(flag.length + (INDENT + INDENT).length);
r = INDENT + INDENT + flag + r;
output.push(r);
}
return output.join("\n");
}
function renderCommand(cmd) {
let output = [];
output.push(INDENT + cmd.usage());
output.push('');
output.push(wrap(cmd.description(), INDENT + INDENT));
const optionString = renderOptions(cmd.options());
if (optionString) {
output.push('');
output.push(optionString);
}
return output.join("\n");
}
function getCommands() {
let output = [];
fs.readdirSync(__dirname).forEach((path) => {
if (path.indexOf('command-') !== 0) return;
const ext = fileExtension(path)
if (ext != 'js') return;
let CommandClass = require('./' + path);
let cmd = new CommandClass();
if (!cmd.enabled()) return;
if (cmd.hidden()) return;
output.push(cmd);
});
return output;
}
function getOptionColWidth(options) {
let output = 0;
for (let j = 0; j < options.length; j++) {
const option = options[j];
if (option[0].length > output) output = option[0].length;
}
return output;
}
function getHeader() {
let output = [];
output.push('NAME');
output.push('');
output.push(wrap('joplin - a note taking and to-do app with synchronisation capabilities'), INDENT);
output.push('');
output.push('DESCRIPTION');
output.push('');
let description = [];
description.push('Joplin is a note taking and to-do application, which can handle a large number of notes organised into notebooks.');
description.push('The notes are searchable, can be copied, tagged and modified with your own text editor.');
description.push("\n\n");
description.push('The notes can be synchronised with various target including the file system (for example with a network directory) or with Microsoft OneDrive.');
description.push("\n\n");
description.push('Notes exported from Evenotes via .enex files can be imported into Joplin, including the formatted content, resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.).');
output.push(wrap(description.join(''), INDENT));
return output.join("\n");
}
function getFooter() {
let output = [];
output.push('WEBSITE');
output.push('');
output.push(INDENT + 'https://joplinapp.org');
output.push('');
output.push('LICENSE');
output.push('');
let filePath = rootDir + '/LICENSE_' + languageCode();
if (!fs.existsSync(filePath)) filePath = rootDir + '/LICENSE';
const licenseText = fs.readFileSync(filePath, 'utf8');
output.push(wrap(licenseText, INDENT));
return output.join("\n");
}
async function main() {
// setLocale('fr_FR');
const commands = getCommands();
let commandBlocks = [];
for (let i = 0; i < commands.length; i++) {
let cmd = commands[i];
commandBlocks.push(renderCommand(cmd));
}
const headerText = getHeader();
const commandsText = commandBlocks.join("\n\n");
const footerText = getFooter();
console.info(headerText + "\n\n" + 'USAGE' + "\n\n" + commandsText + "\n\n" + footerText);
}
main().catch((error) => {
console.error(error);
});

View File

@ -1,249 +0,0 @@
"use strict"
const fs = require('fs-extra');
const { Logger } = require('lib/logger.js');
const { dirname } = require('lib/path-utils.js');
const { DatabaseDriverNode } = require('lib/database-driver-node.js');
const { JoplinDatabase } = require('lib/joplin-database.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const Setting = require('lib/models/Setting.js');
const { sprintf } = require('sprintf-js');
const exec = require('child_process').exec
process.on('unhandledRejection', (reason, p) => {
console.error('Unhandled promise rejection', p, 'reason:', reason);
});
const baseDir = dirname(__dirname) + '/tests/cli-integration';
const joplinAppPath = __dirname + '/main.js';
const logger = new Logger();
logger.addTarget('console');
logger.setLevel(Logger.LEVEL_ERROR);
const dbLogger = new Logger();
dbLogger.addTarget('console');
dbLogger.setLevel(Logger.LEVEL_INFO);
const db = new JoplinDatabase(new DatabaseDriverNode());
db.setLogger(dbLogger);
function createClient(id) {
return {
'id': id,
'profileDir': baseDir + '/client' + id,
};
}
const client = createClient(1);
function execCommand(client, command, options = {}) {
let exePath = 'node ' + joplinAppPath;
let cmd = exePath + ' --update-geolocation-disabled --env dev --profile ' + client.profileDir + ' ' + command;
logger.info(client.id + ': ' + command);
return new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
if (error) {
logger.error(stderr);
reject(error);
} else {
resolve(stdout.trim());
}
});
});
}
function assertTrue(v) {
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
process.stdout.write('.');
}
function assertFalse(v) {
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
process.stdout.write('.');
}
function assertEquals(expected, real) {
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
process.stdout.write('.');
}
async function clearDatabase() {
await db.transactionExecBatch([
'DELETE FROM folders',
'DELETE FROM notes',
'DELETE FROM tags',
'DELETE FROM note_tags',
'DELETE FROM resources',
'DELETE FROM deleted_items',
]);
}
const testUnits = {};
testUnits.testFolders = async () => {
await execCommand(client, 'mkbook nb1');
let folders = await Folder.all();
assertEquals(1, folders.length);
assertEquals('nb1', folders[0].title);
await execCommand(client, 'mkbook nb1');
folders = await Folder.all();
assertEquals(1, folders.length);
assertEquals('nb1', folders[0].title);
await execCommand(client, 'rm -r -f nb1');
folders = await Folder.all();
assertEquals(0, folders.length);
}
testUnits.testNotes = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
let notes = await Note.all();
assertEquals(1, notes.length);
assertEquals('n1', notes[0].title);
await execCommand(client, 'rm -f n1');
notes = await Note.all();
assertEquals(0, notes.length);
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
notes = await Note.all();
assertEquals(2, notes.length);
await execCommand(client, "rm -f 'blabla*'");
notes = await Note.all();
assertEquals(2, notes.length);
await execCommand(client, "rm -f 'n*'");
notes = await Note.all();
assertEquals(0, notes.length);
}
testUnits.testCat = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote mynote');
let folder = await Folder.loadByTitle('nb1');
let note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
let r = await execCommand(client, 'cat mynote');
assertTrue(r.indexOf('mynote') >= 0);
assertFalse(r.indexOf(note.id) >= 0);
r = await execCommand(client, 'cat -v mynote');
assertTrue(r.indexOf(note.id) >= 0);
}
testUnits.testConfig = async () => {
await execCommand(client, 'config editor vim');
await Setting.load();
assertEquals('vim', Setting.value('editor'));
await execCommand(client, 'config editor subl');
await Setting.load();
assertEquals('subl', Setting.value('editor'));
let r = await execCommand(client, 'config');
assertTrue(r.indexOf('editor') >= 0);
assertTrue(r.indexOf('subl') >= 0);
}
testUnits.testCp = async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'cp n1');
let f1 = await Folder.loadByTitle('nb1');
let f2 = await Folder.loadByTitle('nb2');
let notes = await Note.previews(f1.id);
assertEquals(2, notes.length);
await execCommand(client, 'cp n1 nb2');
let notesF1 = await Note.previews(f1.id);
assertEquals(2, notesF1.length);
notes = await Note.previews(f2.id);
assertEquals(1, notes.length);
assertEquals(notesF1[0].title, notes[0].title);
}
testUnits.testLs = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
let r = await execCommand(client, 'ls');
assertTrue(r.indexOf('note1') >= 0);
assertTrue(r.indexOf('note2') >= 0);
}
testUnits.testMv = async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mv n1 nb2');
let f1 = await Folder.loadByTitle('nb1');
let f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
assertEquals(0, notes1.length);
assertEquals(1, notes2.length);
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
await execCommand(client, 'mknote blabla');
await execCommand(client, "mv 'note*' nb2");
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
assertEquals(1, notes1.length);
assertEquals(4, notes2.length);
}
async function main(argv) {
await fs.remove(baseDir);
logger.info(await execCommand(client, 'version'));
await db.open({ name: client.profileDir + '/database.sqlite' });
BaseModel.db_ = db;
await Setting.load();
let onlyThisTest = 'testMv';
onlyThisTest = '';
for (let n in testUnits) {
if (!testUnits.hasOwnProperty(n)) continue;
if (onlyThisTest && n != onlyThisTest) continue;
await clearDatabase();
let testName = n.substr(4).toLowerCase();
process.stdout.write(testName + ': ');
await testUnits[n]();
console.info('');
}
}
main(process.argv).catch((error) => {
console.info('');
logger.error(error);
});

View File

@ -1,250 +0,0 @@
const yargParser = require('yargs-parser');
const { _ } = require('lib/locale.js');
const { time } = require('lib/time-utils.js');
const stringPadding = require('string-padding');
const cliUtils = {};
cliUtils.printArray = function(logFunction, rows, headers = null) {
if (!rows.length) return '';
const ALIGN_LEFT = 0;
const ALIGN_RIGHT = 1;
let colWidths = [];
let colAligns = [];
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
for (let j = 0; j < row.length; j++) {
let item = row[j];
let width = item ? item.toString().length : 0;
let align = typeof item == 'number' ? ALIGN_RIGHT : ALIGN_LEFT;
if (!colWidths[j] || colWidths[j] < width) colWidths[j] = width;
if (colAligns.length <= j) colAligns[j] = align;
}
}
let lines = [];
for (let row = 0; row < rows.length; row++) {
let line = [];
for (let col = 0; col < colWidths.length; col++) {
let item = rows[row][col];
let width = colWidths[col];
let dir = colAligns[col] == ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
line.push(stringPadding(item, width, ' ', dir));
}
logFunction(line.join(' '));
}
}
cliUtils.parseFlags = function(flags) {
let output = {};
flags = flags.split(',');
for (let i = 0; i < flags.length; i++) {
let f = flags[i].trim();
if (f.substr(0, 2) == '--') {
f = f.split(' ');
output.long = f[0].substr(2).trim();
if (f.length == 2) {
output.arg = cliUtils.parseCommandArg(f[1].trim());
}
} else if (f.substr(0, 1) == '-') {
output.short = f.substr(1);
}
}
return output;
}
cliUtils.parseCommandArg = function(arg) {
if (arg.length <= 2) throw new Error('Invalid command arg: ' + arg);
const c1 = arg[0];
const c2 = arg[arg.length - 1];
const name = arg.substr(1, arg.length - 2);
if (c1 == '<' && c2 == '>') {
return { required: true, name: name };
} else if (c1 == '[' && c2 == ']') {
return { required: false, name: name };
} else {
throw new Error('Invalid command arg: ' + arg);
}
}
cliUtils.makeCommandArgs = function(cmd, argv) {
let cmdUsage = cmd.usage();
cmdUsage = yargParser(cmdUsage);
let output = {};
let options = cmd.options();
let booleanFlags = [];
let aliases = {};
for (let i = 0; i < options.length; i++) {
if (options[i].length != 2) throw new Error('Invalid options: ' + options[i]);
let flags = options[i][0];
let text = options[i][1];
flags = cliUtils.parseFlags(flags);
if (!flags.arg) {
booleanFlags.push(flags.short);
if (flags.long) booleanFlags.push(flags.long);
}
if (flags.short && flags.long) {
aliases[flags.long] = [flags.short];
}
}
let args = yargParser(argv, {
boolean: booleanFlags,
alias: aliases,
string: ['_'],
});
for (let i = 1; i < cmdUsage['_'].length; i++) {
const a = cliUtils.parseCommandArg(cmdUsage['_'][i]);
if (a.required && !args['_'][i]) throw new Error(_('Missing required argument: %s', a.name));
if (i >= a.length) {
output[a.name] = null;
} else {
output[a.name] = args['_'][i];
}
}
let argOptions = {};
for (let key in args) {
if (!args.hasOwnProperty(key)) continue;
if (key == '_') continue;
argOptions[key] = args[key];
}
output.options = argOptions;
return output;
}
cliUtils.promptMcq = function(message, answers) {
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
message += "\n\n";
for (let n in answers) {
if (!answers.hasOwnProperty(n)) continue;
message += _('%s: %s', n, answers[n]) + "\n";
}
message += "\n";
message += _('Your choice: ');
return new Promise((resolve, reject) => {
rl.question(message, (answer) => {
rl.close();
if (!(answer in answers)) {
reject(new Error(_('Invalid answer: %s', answer)));
return;
}
resolve(answer);
});
});
}
cliUtils.promptConfirm = function(message, answers = null) {
if (!answers) answers = [_('Y'), _('n')];
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
message += ' (' + answers.join('/') + ')';
return new Promise((resolve, reject) => {
rl.question(message + ' ', (answer) => {
const ok = !answer || answer.toLowerCase() == answers[0].toLowerCase();
rl.close();
resolve(ok);
});
});
}
// Note: initialText is there to have the same signature as statusBar.prompt() so that
// it can be a drop-in replacement, however initialText is not used (and cannot be
// with readline.question?).
cliUtils.prompt = function(initialText = '', promptString = ':', options = null) {
if (!options) options = {};
const readline = require('readline');
const Writable = require('stream').Writable;
const mutableStdout = new Writable({
write: function(chunk, encoding, callback) {
if (!this.muted)
process.stdout.write(chunk, encoding);
callback();
}
});
const rl = readline.createInterface({
input: process.stdin,
output: mutableStdout,
terminal: true,
});
return new Promise((resolve, reject) => {
mutableStdout.muted = false;
rl.question(promptString, (answer) => {
rl.close();
if (!!options.secure) this.stdout_('');
resolve(answer);
});
mutableStdout.muted = !!options.secure;
});
}
let redrawStarted_ = false;
let redrawLastLog_ = null;
let redrawLastUpdateTime_ = 0;
cliUtils.setStdout = function(v) {
this.stdout_ = v;
}
cliUtils.redraw = function(s) {
const now = time.unixMs();
if (now - redrawLastUpdateTime_ > 4000) {
this.stdout_(s);
redrawLastUpdateTime_ = now;
redrawLastLog_ = null;
} else {
redrawLastLog_ = s;
}
redrawStarted_ = true;
}
cliUtils.redrawDone = function() {
if (!redrawStarted_) return;
if (redrawLastLog_) {
this.stdout_(redrawLastLog_);
}
redrawLastLog_ = null;
redrawStarted_ = false;
}
module.exports = { cliUtils };

View File

@ -1,299 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { _ } = require('lib/locale.js');
const { cliUtils } = require('./cli-utils.js');
const EncryptionService = require('lib/services/EncryptionService');
const DecryptionWorker = require('lib/services/DecryptionWorker');
const MasterKey = require('lib/models/MasterKey');
const BaseItem = require('lib/models/BaseItem');
const BaseModel = require('lib/BaseModel');
const Setting = require('lib/models/Setting.js');
const { toTitleCase } = require('lib/string-utils.js');
const { reg } = require('lib/registry.js');
const markdownUtils = require('lib/markdownUtils');
const { Database } = require('lib/database.js');
class Command extends BaseCommand {
usage() {
return 'apidoc';
}
description() {
return 'Build the API doc';
}
createPropertiesTable(tableFields) {
const headers = [
{ name: 'name', label: 'Name' },
{ name: 'type', label: 'Type', filter: (value) => {
return Database.enumName('fieldType', value);
}},
{ name: 'description', label: 'Description' },
];
return markdownUtils.createMarkdownTable(headers, tableFields);
}
async action(args) {
const models = [
{
type: BaseModel.TYPE_NOTE,
},
{
type: BaseModel.TYPE_FOLDER,
},
{
type: BaseModel.TYPE_RESOURCE,
},
{
type: BaseModel.TYPE_TAG,
},
];
const lines = [];
lines.push('# Joplin API');
lines.push('');
lines.push('When the Web Clipper service is enabled, Joplin exposes a [REST API](https://en.wikipedia.org/wiki/Representational_state_transfer) which allows third-party applications to access Joplin\'s data and to create, modify or delete notes, notebooks, resources or tags.');
lines.push('');
lines.push('In order to use it, you\'ll first need to find on which port the service is running. To do so, open the Web Clipper Options in Joplin and if the service is running it should tell you on which port. Normally it runs on port **41184**. If you want to find it programmatically, you may follow this kind of algorithm:');
lines.push('');
lines.push('```javascript');
lines.push('let port = null;');
lines.push('for (let portToTest = 41184; portToTest <= 41194; portToTest++) {');
lines.push(' const result = pingPort(portToTest); // Call GET /ping');
lines.push(' if (result == \'JoplinClipperServer\') {');
lines.push(' port = portToTest; // Found the port');
lines.push(' break;');
lines.push(' }');
lines.push('}');
lines.push('```');
lines.push('');
lines.push('# Authorisation')
lines.push('');
lines.push('To prevent unauthorised applications from accessing the API, the calls must be authentified. To do so, you must provide a token as a query parameter for each API call. You can get this token from the Joplin desktop application, on the Web Clipper Options screen.');
lines.push('');
lines.push('This would be an example of valid cURL call using a token:');
lines.push('');
lines.push('\tcurl http://localhost:41184/notes?token=ABCD123ABCD123ABCD123ABCD123ABCD123');
lines.push('');
lines.push('In the documentation below, the token will not be specified every time however you will need to include it.');
lines.push('');
lines.push('# Using the API');
lines.push('');
lines.push('All the calls, unless noted otherwise, receives and send **JSON data**. For example to create a new note:');
lines.push('');
lines.push('\tcurl --data \'{ "title": "My note", "body": "Some note in **Markdown**"}\' http://localhost:41184/notes');
lines.push('');
lines.push('In the documentation below, the calls may include special parameters such as :id or :note_id. You would replace this with the item ID or note ID.');
lines.push('');
lines.push('For example, for the endpoint `DELETE /tags/:id/notes/:note_id`, to remove the tag with ID "ABCD1234" from the note with ID "EFGH789", you would run for example:');
lines.push('');
lines.push('\tcurl -X DELETE http://localhost:41184/tags/ABCD1234/notes/EFGH789');
lines.push('');
lines.push('The four verbs supported by the API are the following ones:');
lines.push('');
lines.push('* **GET**: To retrieve items (notes, notebooks, etc.).');
lines.push('* **POST**: To create new items. In general most item properties are optional. If you omit any, a default value will be used.');
lines.push('* **PUT**: To update an item. Note in a REST API, traditionally PUT is used to completely replace an item, however in this API it will only replace the properties that are provided. For example if you PUT {"title": "my new title"}, only the "title" property will be changed. The other properties will be left untouched (they won\'t be cleared nor changed).');
lines.push('* **DELETE**: To delete items.');
lines.push('');
lines.push('# Filtering data');
lines.push('');
lines.push('You can change the fields that will be returned by the API using the `fields=` query parameter, which takes a list of comma separated fields. For example, to get the longitude and latitude of a note, use this:');
lines.push('');
lines.push('\tcurl http://localhost:41184/notes/ABCD123?fields=longitude,latitude');
lines.push('');
lines.push('To get the IDs only of all the tags:');
lines.push('');
lines.push('\tcurl http://localhost:41184/tags?fields=id');
lines.push('');
lines.push('# Error handling');
lines.push('');
lines.push('In case of an error, an HTTP status code >= 400 will be returned along with a JSON object that provides more info about the error. The JSON object is in the format `{ "error": "description of error" }`.');
lines.push('');
lines.push('# About the property types');
lines.push('');
lines.push('* Text is UTF-8.');
lines.push('* All date/time are Unix timestamps in milliseconds.');
lines.push('* Booleans are integer values 0 or 1.');
lines.push('');
lines.push('# Testing if the service is available');
lines.push('');
lines.push('Call **GET /ping** to check if the service is available. It should return "JoplinClipperServer" if it works.');
lines.push('');
lines.push('# Searching');
lines.push('');
lines.push('Call **GET /search?query=YOUR_QUERY** to search for notes. This end-point supports the `field` parameter which is recommended to use so that you only get the data that you need. The query syntax is as described in the main documentation: https://joplinapp.org/#searching');
lines.push('');
for (let i = 0; i < models.length; i++) {
const model = models[i];
const ModelClass = BaseItem.getClassByItemType(model.type);
const tableName = ModelClass.tableName();
let tableFields = reg.db().tableFields(tableName, { includeDescription: true });
const singular = tableName.substr(0, tableName.length - 1);
if (model.type === BaseModel.TYPE_NOTE) {
tableFields = tableFields.slice();
tableFields.push({
name: 'body_html',
type: Database.enumId('fieldType', 'text'),
description: 'Note body, in HTML format',
});
tableFields.push({
name: 'base_url',
type: Database.enumId('fieldType', 'text'),
description: 'If `body_html` is provided and contains relative URLs, provide the `base_url` parameter too so that all the URLs can be converted to absolute ones. The base URL is basically where the HTML was fetched from, minus the query (everything after the \'?\'). For example if the original page was `https://stackoverflow.com/search?q=%5Bjava%5D+test`, the base URL is `https://stackoverflow.com/search`.',
});
tableFields.push({
name: 'image_data_url',
type: Database.enumId('fieldType', 'text'),
description: 'An image to attach to the note, in [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.',
});
tableFields.push({
name: 'crop_rect',
type: Database.enumId('fieldType', 'text'),
description: 'If an image is provided, you can also specify an optional rectangle that will be used to crop the image. In format `{ x: x, y: y, width: width, height: height }`',
});
// tableFields.push({
// name: 'tags',
// type: Database.enumId('fieldType', 'text'),
// description: 'Comma-separated list of tags. eg. `tag1,tag2`.',
// });
}
lines.push('# ' + toTitleCase(tableName));
lines.push('');
if (model.type === BaseModel.TYPE_FOLDER) {
lines.push('This is actually a notebook. Internally notebooks are called "folders".');
lines.push('');
}
lines.push('## Properties');
lines.push('');
lines.push(this.createPropertiesTable(tableFields));
lines.push('');
lines.push('## GET /' + tableName);
lines.push('');
lines.push('Gets all ' + tableName);
lines.push('');
if (model.type === BaseModel.TYPE_FOLDER) {
lines.push('The folders are returned as a tree. The sub-notebooks of a notebook, if any, are under the `children` key.');
lines.push('');
}
lines.push('## GET /' + tableName + '/:id');
lines.push('');
lines.push('Gets ' + singular + ' with ID :id');
lines.push('');
if (model.type === BaseModel.TYPE_TAG) {
lines.push('## GET /tags/:id/notes');
lines.push('');
lines.push('Gets all the notes with this tag.');
lines.push('');
}
if (model.type === BaseModel.TYPE_NOTE) {
lines.push('## GET /notes/:id/tags');
lines.push('');
lines.push('Gets all the tags attached to this note.');
lines.push('');
}
if (model.type === BaseModel.TYPE_FOLDER) {
lines.push('## GET /folders/:id/notes');
lines.push('');
lines.push('Gets all the notes inside this folder.');
lines.push('');
}
if (model.type === BaseModel.TYPE_RESOURCE) {
lines.push('## GET /resources/:id/file');
lines.push('');
lines.push('Gets the actual file associated with this resource.');
lines.push('');
}
lines.push('## POST /' + tableName);
lines.push('');
lines.push('Creates a new ' + singular);
lines.push('');
if (model.type === BaseModel.TYPE_RESOURCE) {
lines.push('Creating a new resource is special because you also need to upload the file. Unlike other API calls, this one must have the "multipart/form-data" Content-Type. The file data must be passed to the "data" form field, and the other properties to the "props" form field. An example of a valid call with cURL would be:');
lines.push('');
lines.push('\tcurl -F \'data=@/path/to/file.jpg\' -F \'props={"title":"my resource title"}\' http://localhost:41184/resources');
lines.push('');
lines.push('The "data" field is required, while the "props" one is not. If not specified, default values will be used.');
lines.push('');
}
if (model.type === BaseModel.TYPE_TAG) {
lines.push('## POST /tags/:id/notes');
lines.push('');
lines.push('Post a note to this endpoint to add the tag to the note. The note data must at least contain an ID property (all other properties will be ignored).');
lines.push('');
}
if (model.type === BaseModel.TYPE_NOTE) {
lines.push('You can either specify the note body as Markdown by setting the `body` parameter, or in HTML by setting the `body_html`.');
lines.push('');
lines.push('Examples:');
lines.push('');
lines.push('* Create a note from some Markdown text');
lines.push('');
lines.push(' curl --data \'{ "title": "My note", "body": "Some note in **Markdown**"}\' http://127.0.0.1:41184/notes');
lines.push('');
lines.push('* Create a note from some HTML');
lines.push('');
lines.push(' curl --data \'{ "title": "My note", "body_html": "Some note in <b>HTML</b>"}\' http://127.0.0.1:41184/notes');
lines.push('');
lines.push('* Create a note and attach an image to it:');
lines.push('');
lines.push(' curl --data \'{ "title": "Image test", "body": "Here is Joplin icon:", "image_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAANZJREFUeNoAyAA3/wFwtO3K6gUB/vz2+Prw9fj/+/r+/wBZKAAExOgF4/MC9ff+MRH6Ui4E+/0Bqc/zutj6AgT+/Pz7+vv7++nu82c4DlMqCvLs8goA/gL8/fz09fb59vXa6vzZ6vjT5fbn6voD/fwC8vX4UiT9Zi//APHyAP8ACgUBAPv5APz7BPj2+DIaC2o3E+3o6ywaC5fT6gD6/QD9/QEVf9kD+/dcLQgJA/7v8vqfwOf18wA1IAIEVycAyt//v9XvAPv7APz8LhoIAPz9Ri4OAgwARgx4W/6fVeEAAAAASUVORK5CYII="}\' http://127.0.0.1:41184/notes');
lines.push('');
lines.push('### Creating a note with a specific ID');
lines.push('');
lines.push('When a new note is created, it is automatically assigned a new unique ID so **normally you do not need to set the ID**. However, if for some reason you want to set it, you can supply it as the `id` property. It needs to be a 32 characters long hexadecimal string. **Make sure it is unique**, for example by generating it using whatever GUID function is available in your programming language.');
lines.push('');
lines.push(' curl --data \'{ "id": "00a87474082744c1a8515da6aa5792d2", "title": "My note with custom ID"}\' http://127.0.0.1:41184/notes');
lines.push('');
}
lines.push('## PUT /' + tableName + '/:id');
lines.push('');
lines.push('Sets the properties of the ' + singular + ' with ID :id');
lines.push('');
lines.push('## DELETE /' + tableName + '/:id');
lines.push('');
lines.push('Deletes the ' + singular + ' with ID :id');
lines.push('');
if (model.type === BaseModel.TYPE_TAG) {
lines.push('## DELETE /tags/:id/notes/:note_id');
lines.push('');
lines.push('Remove the tag from the note.');
lines.push('');
}
}
this.stdout(lines.join('\n'));
}
}
module.exports = Command;

View File

@ -1,32 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const { shim } = require('lib/shim.js');
const fs = require('fs-extra');
class Command extends BaseCommand {
usage() {
return 'attach <note> <file>';
}
description() {
return _('Attaches the given file to the note.');
}
async action(args) {
let title = args['note'];
let note = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
this.encryptionCheck(note);
if (!note) throw new Error(_('Cannot find "%s".', title));
const localFilePath = args['file'];
await shim.attachFileToNote(note, localFilePath);
}
}
module.exports = Command;

View File

@ -1,39 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
class Command extends BaseCommand {
usage() {
return 'cat <note>';
}
description() {
return _('Displays the given note.');
}
options() {
return [
['-v, --verbose', _('Displays the complete information about note.')],
];
}
async action(args) {
let title = args['note'];
let item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title));
const content = args.options.verbose ? await Note.serialize(item) : await Note.serializeForEdit(item);
this.stdout(content);
app().gui().showConsole();
app().gui().maximizeConsole();
}
}
module.exports = Command;

View File

@ -1,70 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { _, setLocale } = require('lib/locale.js');
const { app } = require('./app.js');
const Setting = require('lib/models/Setting.js');
class Command extends BaseCommand {
usage() {
return 'config [name] [value]';
}
description() {
return _("Gets or sets a config value. If [value] is not provided, it will show the value of [name]. If neither [name] nor [value] is provided, it will list the current configuration.");
}
options() {
return [
['-v, --verbose', _('Also displays unset and hidden config variables.')],
];
}
async action(args) {
const verbose = args.options.verbose;
const renderKeyValue = (name) => {
const md = Setting.settingMetadata(name);
let value = Setting.value(name);
if (typeof value === 'object' || Array.isArray(value)) value = JSON.stringify(value);
if (md.secure && value) value = '********';
if (Setting.isEnum(name)) {
return _('%s = %s (%s)', name, value, Setting.enumOptionsDoc(name));
} else {
return _('%s = %s', name, value);
}
}
if (!args.name && !args.value) {
let keys = Setting.keys(!verbose, 'cli');
keys.sort();
for (let i = 0; i < keys.length; i++) {
const value = Setting.value(keys[i]);
if (!verbose && !value) continue;
this.stdout(renderKeyValue(keys[i]));
}
app().gui().showConsole();
app().gui().maximizeConsole();
return;
}
if (args.name && !args.value) {
this.stdout(renderKeyValue(args.name));
app().gui().showConsole();
app().gui().maximizeConsole();
return;
}
Setting.setValue(args.name, args.value);
if (args.name == 'locale') {
setLocale(Setting.value('locale'));
app().onLocaleChanged();
}
await Setting.saveAll();
}
}
module.exports = Command;

View File

@ -1,39 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
class Command extends BaseCommand {
usage() {
return 'cp <note> [notebook]';
}
description() {
return _('Duplicates the notes matching <note> to [notebook]. If no notebook is specified the note is duplicated in the current notebook.');
}
async action(args) {
let folder = null;
if (args['notebook']) {
folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
} else {
folder = app().currentFolder();
}
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
const notes = await app().loadItems(BaseModel.TYPE_NOTE, args['note']);
if (!notes.length) throw new Error(_('Cannot find "%s".', args['note']));
for (let i = 0; i < notes.length; i++) {
const newNote = await Note.copyToFolder(notes[i].id, folder.id);
Note.updateGeolocation(newNote.id);
}
}
}
module.exports = Command;

View File

@ -1,41 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const { time } = require('lib/time-utils.js');
class Command extends BaseCommand {
usage() {
return 'done <note>';
}
description() {
return _('Marks a to-do as done.');
}
static async handleAction(commandInstance, args, isCompleted) {
const note = await app().loadItem(BaseModel.TYPE_NOTE, args.note);
commandInstance.encryptionCheck(note);
if (!note) throw new Error(_('Cannot find "%s".', args.note));
if (!note.is_todo) throw new Error(_('Note is not a to-do: "%s"', args.note));
const todoCompleted = !!note.todo_completed;
if (isCompleted === todoCompleted) return;
await Note.save({
id: note.id,
todo_completed: isCompleted ? time.unixMs() : 0,
});
}
async action(args) {
await Command.handleAction(this, args, true);
}
}
module.exports = Command;

View File

@ -1,44 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const Tag = require('lib/models/Tag.js');
class Command extends BaseCommand {
usage() {
return 'dump';
}
description() {
return 'Dumps the complete database as JSON.';
}
hidden() {
return true;
}
async action(args) {
let items = [];
let folders = await Folder.all();
for (let i = 0; i < folders.length; i++) {
let folder = folders[i];
let notes = await Note.previews(folder.id);
items.push(folder);
items = items.concat(notes);
}
let tags = await Tag.all();
for (let i = 0; i < tags.length; i++) {
tags[i].notes_ = await Tag.noteIds(tags[i].id);
}
items = items.concat(tags);
this.stdout(JSON.stringify(items));
}
}
module.exports = Command;

View File

@ -1,230 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { _ } = require('lib/locale.js');
const { cliUtils } = require('./cli-utils.js');
const EncryptionService = require('lib/services/EncryptionService');
const DecryptionWorker = require('lib/services/DecryptionWorker');
const MasterKey = require('lib/models/MasterKey');
const BaseItem = require('lib/models/BaseItem');
const Setting = require('lib/models/Setting.js');
const { shim } = require('lib/shim');
const pathUtils = require('lib/path-utils.js');
const imageType = require('image-type');
const readChunk = require('read-chunk');
class Command extends BaseCommand {
usage() {
return 'e2ee <command> [path]';
}
description() {
return _('Manages E2EE configuration. Commands are `enable`, `disable`, `decrypt`, `status`, `decrypt-file` and `target-status`.');
}
options() {
return [
// This is here mostly for testing - shouldn't be used
['-p, --password <password>', 'Use this password as master password (For security reasons, it is not recommended to use this option).'],
['-v, --verbose', 'More verbose output for the `target-status` command'],
['-o, --output <directory>', 'Output directory'],
];
}
async action(args) {
// change-password
const options = args.options;
const askForMasterKey = async (error) => {
const masterKeyId = error.masterKeyId;
const password = await this.prompt(_('Enter master password:'), { type: 'string', secure: true });
if (!password) {
this.stdout(_('Operation cancelled'));
return false;
}
Setting.setObjectKey('encryption.passwordCache', masterKeyId, password);
await EncryptionService.instance().loadMasterKeysFromSettings();
return true;
}
if (args.command === 'enable') {
const password = options.password ? options.password.toString() : await this.prompt(_('Enter master password:'), { type: 'string', secure: true });
if (!password) {
this.stdout(_('Operation cancelled'));
return;
}
await EncryptionService.instance().generateMasterKeyAndEnableEncryption(password);
return;
}
if (args.command === 'disable') {
await EncryptionService.instance().disableEncryption();
return;
}
if (args.command === 'decrypt') {
if (args.path) {
const plainText = await EncryptionService.instance().decryptString(args.path);
this.stdout(plainText);
} else {
this.stdout(_('Starting decryption... Please wait as it may take several minutes depending on how much there is to decrypt.'));
while (true) {
try {
await DecryptionWorker.instance().start();
break;
} catch (error) {
if (error.code === 'masterKeyNotLoaded') {
const ok = await askForMasterKey(error);
if (!ok) return;
continue;
}
throw error;
}
}
this.stdout(_('Completed decryption.'));
}
return;
}
if (args.command === 'status') {
this.stdout(_('Encryption is: %s', Setting.value('encryption.enabled') ? _('Enabled') : _('Disabled')));
return;
}
if (args.command === 'decrypt-file') {
while (true) {
try {
const outputDir = options.output ? options.output : require('os').tmpdir();
let outFile = outputDir + '/' + pathUtils.filename(args.path) + '.' + Date.now() + '.bin';
await EncryptionService.instance().decryptFile(args.path, outFile);
const buffer = await readChunk(outFile, 0, 64);
const detectedType = imageType(buffer);
if (detectedType) {
const newOutFile = outFile + '.' + detectedType.ext;
await shim.fsDriver().move(outFile, newOutFile);
outFile = newOutFile;
}
this.stdout(outFile);
break;
} catch (error) {
if (error.code === 'masterKeyNotLoaded') {
const ok = await askForMasterKey(error);
if (!ok) return;
continue;
}
throw error;
}
}
return;
}
if (args.command === 'target-status') {
const fs = require('fs-extra');
const pathUtils = require('lib/path-utils.js');
const fsDriver = new (require('lib/fs-driver-node.js').FsDriverNode)();
const targetPath = args.path;
if (!targetPath) throw new Error('Please specify the sync target path.');
const dirPaths = function(targetPath) {
let paths = [];
fs.readdirSync(targetPath).forEach((path) => {
paths.push(path);
});
return paths;
}
let itemCount = 0;
let resourceCount = 0;
let encryptedItemCount = 0;
let encryptedResourceCount = 0;
let otherItemCount = 0;
let encryptedPaths = [];
let decryptedPaths = [];
let paths = dirPaths(targetPath);
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
const fullPath = targetPath + '/' + path;
const stat = await fs.stat(fullPath);
// this.stdout(fullPath);
if (path === '.resource') {
let resourcePaths = dirPaths(fullPath);
for (let j = 0; j < resourcePaths.length; j++) {
const resourcePath = resourcePaths[j];
resourceCount++;
const fullResourcePath = fullPath + '/' + resourcePath;
const isEncrypted = await EncryptionService.instance().fileIsEncrypted(fullResourcePath);
if (isEncrypted) {
encryptedResourceCount++;
encryptedPaths.push(fullResourcePath);
} else {
decryptedPaths.push(fullResourcePath);
}
}
} else if (stat.isDirectory()) {
continue;
} else {
const content = await fs.readFile(fullPath, 'utf8');
const item = await BaseItem.unserialize(content);
const ItemClass = BaseItem.itemClass(item);
if (!ItemClass.encryptionSupported()) {
otherItemCount++;
continue;
}
itemCount++;
const isEncrypted = await EncryptionService.instance().itemIsEncrypted(item);
if (isEncrypted) {
encryptedItemCount++;
encryptedPaths.push(fullPath);
} else {
decryptedPaths.push(fullPath);
}
}
}
this.stdout('Encrypted items: ' + encryptedItemCount + '/' + itemCount);
this.stdout('Encrypted resources: ' + encryptedResourceCount + '/' + resourceCount);
this.stdout('Other items (never encrypted): ' + otherItemCount);
if (options.verbose) {
this.stdout('');
this.stdout('# Encrypted paths');
this.stdout('');
for (let i = 0; i < encryptedPaths.length; i++) {
const path = encryptedPaths[i];
this.stdout(path);
}
this.stdout('');
this.stdout('# Decrypted paths');
this.stdout('');
for (let i = 0; i < decryptedPaths.length; i++) {
const path = decryptedPaths[i];
this.stdout(path);
}
}
return;
}
}
}
module.exports = Command;

View File

@ -1,119 +0,0 @@
const fs = require('fs-extra');
const { BaseCommand } = require('./base-command.js');
const { uuid } = require('lib/uuid.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const Setting = require('lib/models/Setting.js');
const BaseModel = require('lib/BaseModel.js');
const { cliUtils } = require('./cli-utils.js');
const { time } = require('lib/time-utils.js');
class Command extends BaseCommand {
usage() {
return 'edit <note>';
}
description() {
return _('Edit note.');
}
async action(args) {
let watcher = null;
let tempFilePath = null;
const onFinishedEditing = async () => {
if (tempFilePath) fs.removeSync(tempFilePath);
}
const textEditorPath = () => {
if (Setting.value('editor')) return Setting.value('editor');
if (process.env.EDITOR) return process.env.EDITOR;
throw new Error(_('No text editor is defined. Please set it using `config editor <editor-path>`'));
}
try {
// -------------------------------------------------------------------------
// Load note or create it if it doesn't exist
// -------------------------------------------------------------------------
let title = args['note'];
if (!app().currentFolder()) throw new Error(_('No active notebook.'));
let note = await app().loadItem(BaseModel.TYPE_NOTE, title);
this.encryptionCheck(note);
if (!note) {
const ok = await this.prompt(_('Note does not exist: "%s". Create it?', title));
if (!ok) return;
note = await Note.save({ title: title, parent_id: app().currentFolder().id });
note = await Note.load(note.id);
}
// -------------------------------------------------------------------------
// Create the file to be edited and prepare the editor program arguments
// -------------------------------------------------------------------------
let editorPath = textEditorPath();
let editorArgs = editorPath.split(' ');
editorPath = editorArgs[0];
editorArgs = editorArgs.splice(1);
const originalContent = await Note.serializeForEdit(note);
tempFilePath = Setting.value('tempDir') + '/' + uuid.create() + '.md';
editorArgs.push(tempFilePath);
await fs.writeFile(tempFilePath, originalContent);
// -------------------------------------------------------------------------
// Start editing the file
// -------------------------------------------------------------------------
this.logger().info('Disabling fullscreen...');
app().gui().showModalOverlay(_('Starting to edit note. Close the editor to get back to the prompt.'));
await app().gui().forceRender();
const termState = app().gui().termSaveState();
const spawnSync = require('child_process').spawnSync;
const result = spawnSync(editorPath, editorArgs, { stdio: 'inherit' });
if (result.error) this.stdout(_('Error opening note in editor: %s', result.error.message));
app().gui().termRestoreState(termState);
app().gui().hideModalOverlay();
app().gui().forceRender();
// -------------------------------------------------------------------------
// Save the note and clean up
// -------------------------------------------------------------------------
const updatedContent = await fs.readFile(tempFilePath, 'utf8');
if (updatedContent !== originalContent) {
let updatedNote = await Note.unserializeForEdit(updatedContent);
updatedNote.id = note.id;
await Note.save(updatedNote);
this.stdout(_('Note has been saved.'));
}
this.dispatch({
type: 'NOTE_SELECT',
id: note.id,
});
await onFinishedEditing();
} catch(error) {
await onFinishedEditing();
throw error;
}
}
}
module.exports = Command;

View File

@ -1,25 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
class Command extends BaseCommand {
usage() {
return 'exit';
}
description() {
return _('Exits the application.');
}
compatibleUis() {
return ['gui'];
}
async action(args) {
await app().exit();
}
}
module.exports = Command;

View File

@ -1,36 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { Database } = require('lib/database.js');
const { app } = require('./app.js');
const Setting = require('lib/models/Setting.js');
const { _ } = require('lib/locale.js');
const { ReportService } = require('lib/services/report.js');
const fs = require('fs-extra');
class Command extends BaseCommand {
usage() {
return 'export-sync-status';
}
description() {
return 'Export sync status';
}
hidden() {
return true;
}
async action(args) {
const service = new ReportService();
const csv = await service.basicItemList({ format: 'csv' });
const filePath = Setting.value('profileDir') + '/syncReport-' + (new Date()).getTime() + '.csv';
await fs.writeFileSync(filePath, csv);
this.stdout('Sync status exported to ' + filePath);
app().gui().showConsole();
app().gui().maximizeConsole();
}
}
module.exports = Command;

View File

@ -1,61 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const InteropService = require('lib/services/InteropService.js');
const BaseModel = require('lib/BaseModel.js');
const Note = require('lib/models/Note.js');
const { reg } = require('lib/registry.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const fs = require('fs-extra');
class Command extends BaseCommand {
usage() {
return 'export <path>';
}
description() {
return _('Exports Joplin data to the given path. By default, it will export the complete database including notebooks, notes, tags and resources.');
}
options() {
const service = new InteropService();
const formats = service.modules()
.filter(m => m.type === 'exporter')
.map(m => m.format + (m.description ? ' (' + m.description + ')' : ''));
return [
['--format <format>', _('Destination format: %s', formats.join(', '))],
['--note <note>', _('Exports only the given note.')],
['--notebook <notebook>', _('Exports only the given notebook.')],
];
}
async action(args) {
let exportOptions = {};
exportOptions.path = args.path;
exportOptions.format = args.options.format ? args.options.format : 'jex';
if (args.options.note) {
const notes = await app().loadItems(BaseModel.TYPE_NOTE, args.options.note, { parent: app().currentFolder() });
if (!notes.length) throw new Error(_('Cannot find "%s".', args.options.note));
exportOptions.sourceNoteIds = notes.map((n) => n.id);
} else if (args.options.notebook) {
const folders = await app().loadItems(BaseModel.TYPE_FOLDER, args.options.notebook);
if (!folders.length) throw new Error(_('Cannot find "%s".', args.options.notebook));
exportOptions.sourceFolderIds = folders.map((n) => n.id);
}
const service = new InteropService();
const result = await service.export(exportOptions);
result.warnings.map((w) => this.stdout(w));
}
}
module.exports = Command;

View File

@ -1,31 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
class Command extends BaseCommand {
usage() {
return 'geoloc <note>';
}
description() {
return _('Displays a geolocation URL for the note.');
}
async action(args) {
let title = args['note'];
let item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title));
const url = Note.geolocationUrl(item);
this.stdout(url);
app().gui().showConsole();
}
}
module.exports = Command;

View File

@ -1,91 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { renderCommandHelp } = require('./help-utils.js');
const { Database } = require('lib/database.js');
const Setting = require('lib/models/Setting.js');
const { wrap } = require('lib/string-utils.js');
const { _ } = require('lib/locale.js');
const { cliUtils } = require('./cli-utils.js');
class Command extends BaseCommand {
usage() {
return 'help [command]';
}
description() {
return _('Displays usage information.');
}
allCommands() {
const commands = app().commands(app().uiType());
let output = [];
for (let n in commands) {
if (!commands.hasOwnProperty(n)) continue;
const command = commands[n];
if (command.hidden()) continue;
if (!command.enabled()) continue;
output.push(command);
}
output.sort((a, b) => a.name() < b.name() ? -1 : +1);
return output;
}
async action(args) {
const stdoutWidth = app().commandStdoutMaxWidth();
if (args.command === 'shortcuts' || args.command === 'keymap') {
this.stdout(_('For information on how to customise the shortcuts please visit %s', 'https://joplinapp.org/terminal/#shortcuts'));
this.stdout('');
if (app().gui().isDummy()) {
throw new Error(_('Shortcuts are not available in CLI mode.'));
}
const keymap = app().gui().keymap();
let rows = [];
for (let i = 0; i < keymap.length; i++) {
const item = keymap[i];
const keys = item.keys.map((k) => k === ' ' ? '(SPACE)' : k);
rows.push([keys.join(', '), item.command]);
}
cliUtils.printArray(this.stdout.bind(this), rows);
} else if (args.command === 'all') {
const commands = this.allCommands();
const output = commands.map((c) => renderCommandHelp(c));
this.stdout(output.join('\n\n'));
} else if (args.command) {
const command = app().findCommandByName(args['command']);
if (!command) throw new Error(_('Cannot find "%s".', args.command));
this.stdout(renderCommandHelp(command, stdoutWidth));
} else {
const commandNames = this.allCommands().map((a) => a.name());
this.stdout(_('Type `help [command]` for more information about a command; or type `help all` for the complete usage information.'));
this.stdout('');
this.stdout(_('The possible commands are:'));
this.stdout('');
this.stdout(commandNames.join(', '));
this.stdout('');
this.stdout(_('In any command, a note or notebook can be referred to by title or ID, or using the shortcuts `$n` or `$b` for, respectively, the currently selected note or notebook. `$c` can be used to refer to the currently selected item.'));
this.stdout('');
this.stdout(_('To move from one pane to another, press Tab or Shift+Tab.'));
this.stdout(_('Use the arrows and page up/down to scroll the lists and text areas (including this console).'));
this.stdout(_('To maximise/minimise the console, press "tc".'));
this.stdout(_('To enter command line mode, press ":"'));
this.stdout(_('To exit command line mode, press ESCAPE'));
this.stdout(_('For the list of keyboard shortcuts and config options, type `help keymap`'));
}
app().gui().showConsole();
app().gui().maximizeConsole();
}
}
module.exports = Command;

View File

@ -1,68 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const Folder = require('lib/models/Folder.js');
const { importEnex } = require('lib/import-enex');
const { filename, basename } = require('lib/path-utils.js');
const { cliUtils } = require('./cli-utils.js');
class Command extends BaseCommand {
usage() {
return 'import-enex <file> [notebook]';
}
description() {
return _('Imports an Evernote notebook file (.enex file).');
}
options() {
return [
['-f, --force', _('Do not ask for confirmation.')],
];
}
async action(args) {
let filePath = args.file;
let folder = null;
let folderTitle = args['notebook'];
let force = args.options.force === true;
if (!folderTitle) folderTitle = filename(filePath);
folder = await Folder.loadByField('title', folderTitle);
const msg = folder ? _('File "%s" will be imported into existing notebook "%s". Continue?', basename(filePath), folderTitle) : _('New notebook "%s" will be created and file "%s" will be imported into it. Continue?', folderTitle, basename(filePath));
const ok = force ? true : await this.prompt(msg);
if (!ok) return;
let lastProgress = '';
let options = {
onProgress: (progressState) => {
let line = [];
line.push(_('Found: %d.', progressState.loaded));
line.push(_('Created: %d.', progressState.created));
if (progressState.updated) line.push(_('Updated: %d.', progressState.updated));
if (progressState.skipped) line.push(_('Skipped: %d.', progressState.skipped));
if (progressState.resourcesCreated) line.push(_('Resources: %d.', progressState.resourcesCreated));
if (progressState.notesTagged) line.push(_('Tagged: %d.', progressState.notesTagged));
lastProgress = line.join(' ');
cliUtils.redraw(lastProgress);
},
onError: (error) => {
let s = error.trace ? error.trace : error.toString();
this.stdout(s);
},
}
folder = !folder ? await Folder.save({ title: folderTitle }) : folder;
app().gui().showConsole();
this.stdout(_('Importing notes...'));
await importEnex(folder.id, filePath, options);
cliUtils.redrawDone();
this.stdout(_('The notes have been imported: %s', lastProgress));
}
}
module.exports = Command;

View File

@ -1,75 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const InteropService = require('lib/services/InteropService.js');
const BaseModel = require('lib/BaseModel.js');
const Note = require('lib/models/Note.js');
const { filename, basename, fileExtension } = require('lib/path-utils.js');
const { importEnex } = require('lib/import-enex');
const { cliUtils } = require('./cli-utils.js');
const { reg } = require('lib/registry.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const fs = require('fs-extra');
class Command extends BaseCommand {
usage() {
return 'import <path> [notebook]';
}
description() {
return _('Imports data into Joplin.');
}
options() {
const service = new InteropService();
const formats = service.modules().filter(m => m.type === 'importer').map(m => m.format);
return [
['--format <format>', _('Source format: %s', (['auto'].concat(formats)).join(', '))],
['-f, --force', _('Do not ask for confirmation.')],
];
}
async action(args) {
let folder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
if (args.notebook && !folder) throw new Error(_('Cannot find "%s".', args.notebook));
const importOptions = {};
importOptions.path = args.path;
importOptions.format = args.options.format ? args.options.format : 'auto';
importOptions.destinationFolderId = folder ? folder.id : null;
let lastProgress = '';
// onProgress/onError supported by Enex import only
importOptions.onProgress = (progressState) => {
let line = [];
line.push(_('Found: %d.', progressState.loaded));
line.push(_('Created: %d.', progressState.created));
if (progressState.updated) line.push(_('Updated: %d.', progressState.updated));
if (progressState.skipped) line.push(_('Skipped: %d.', progressState.skipped));
if (progressState.resourcesCreated) line.push(_('Resources: %d.', progressState.resourcesCreated));
if (progressState.notesTagged) line.push(_('Tagged: %d.', progressState.notesTagged));
lastProgress = line.join(' ');
cliUtils.redraw(lastProgress);
};
importOptions.onError = (error) => {
let s = error.trace ? error.trace : error.toString();
this.stdout(s);
};
app().gui().showConsole();
this.stdout(_('Importing notes...'));
const service = new InteropService();
const result = await service.import(importOptions);
result.warnings.map((w) => this.stdout(w));
cliUtils.redrawDone();
if (lastProgress) this.stdout(_('The notes have been imported: %s', lastProgress));
}
}
module.exports = Command;

View File

@ -1,126 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Setting = require('lib/models/Setting.js');
const Note = require('lib/models/Note.js');
const { sprintf } = require('sprintf-js');
const { time } = require('lib/time-utils.js');
const { cliUtils } = require('./cli-utils.js');
class Command extends BaseCommand {
usage() {
return 'ls [note-pattern]';
}
description() {
return _('Displays the notes in the current notebook. Use `ls /` to display the list of notebooks.');
}
enabled() {
return false;
}
options() {
return [
['-n, --limit <num>', _('Displays only the first top <num> notes.')],
['-s, --sort <field>', _('Sorts the item by <field> (eg. title, updated_time, created_time).')],
['-r, --reverse', _('Reverses the sorting order.')],
['-t, --type <type>', _('Displays only the items of the specific type(s). Can be `n` for notes, `t` for to-dos, or `nt` for notes and to-dos (eg. `-tt` would display only the to-dos, while `-ttd` would display notes and to-dos.')],
['-f, --format <format>', _('Either "text" or "json"')],
['-l, --long', _('Use long list format. Format is ID, NOTE_COUNT (for notebook), DATE, TODO_CHECKED (for to-dos), TITLE')],
];
}
async action(args) {
let pattern = args['note-pattern'];
let items = [];
let options = args.options;
let queryOptions = {};
if (options.limit) queryOptions.limit = options.limit;
if (options.sort) {
queryOptions.orderBy = options.sort;
queryOptions.orderByDir = 'ASC';
}
if (options.reverse === true) queryOptions.orderByDir = queryOptions.orderByDir == 'ASC' ? 'DESC' : 'ASC';
queryOptions.caseInsensitive = true;
if (options.type) {
queryOptions.itemTypes = [];
if (options.type.indexOf('n') >= 0) queryOptions.itemTypes.push('note');
if (options.type.indexOf('t') >= 0) queryOptions.itemTypes.push('todo');
}
if (pattern) queryOptions.titlePattern = pattern;
queryOptions.uncompletedTodosOnTop = Setting.value('uncompletedTodosOnTop');
let modelType = null;
if (pattern == '/' || !app().currentFolder()) {
queryOptions.includeConflictFolder = true;
items = await Folder.all(queryOptions);
modelType = Folder.modelType();
} else {
if (!app().currentFolder()) throw new Error(_('Please select a notebook first.'));
items = await Note.previews(app().currentFolder().id, queryOptions);
modelType = Note.modelType();
}
if (options.format && options.format == 'json') {
this.stdout(JSON.stringify(items));
} else {
let hasTodos = false;
for (let i = 0; i < items.length; i++) {
let item = items[i];
if (item.is_todo) {
hasTodos = true;
break;
}
}
let seenTitles = [];
let rows = [];
let shortIdShown = false;
for (let i = 0; i < items.length; i++) {
let item = items[i];
let row = [];
if (options.long) {
row.push(BaseModel.shortId(item.id));
shortIdShown = true;
if (modelType == Folder.modelType()) {
row.push(await Folder.noteCount(item.id));
}
row.push(time.formatMsToLocal(item.user_updated_time));
}
let title = item.title;
if (!shortIdShown && (seenTitles.indexOf(item.title) >= 0 || !item.title)) {
title += ' (' + BaseModel.shortId(item.id) + ')';
} else {
seenTitles.push(item.title);
}
if (hasTodos) {
if (item.is_todo) {
row.push(sprintf('[%s]', !!item.todo_completed ? 'X' : ' '));
} else {
row.push(' ');
}
}
row.push(title);
rows.push(row);
}
cliUtils.printArray(this.stdout.bind(this), rows);
}
}
}
module.exports = Command;

View File

@ -1,24 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const Folder = require('lib/models/Folder.js');
const { reg } = require('lib/registry.js');
class Command extends BaseCommand {
usage() {
return 'mkbook <new-notebook>';
}
description() {
return _('Creates a new notebook.');
}
async action(args) {
let folder = await Folder.save({ title: args['new-notebook'] }, { userSideValidation: true });
app().switchCurrentFolder(folder);
}
}
module.exports = Command;

View File

@ -1,32 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const Note = require('lib/models/Note.js');
class Command extends BaseCommand {
usage() {
return 'mknote <new-note>';
}
description() {
return _('Creates a new note.');
}
async action(args) {
if (!app().currentFolder()) throw new Error(_('Notes can only be created within a notebook.'));
let note = {
title: args['new-note'],
parent_id: app().currentFolder().id,
};
note = await Note.save(note);
Note.updateGeolocation(note.id);
app().switchCurrentFolder(app().currentFolder());
}
}
module.exports = Command;

View File

@ -1,33 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const Note = require('lib/models/Note.js');
class Command extends BaseCommand {
usage() {
return 'mktodo <new-todo>';
}
description() {
return _('Creates a new to-do.');
}
async action(args) {
if (!app().currentFolder()) throw new Error(_('Notes can only be created within a notebook.'));
let note = {
title: args['new-todo'],
parent_id: app().currentFolder().id,
is_todo: 1,
};
note = await Note.save(note);
Note.updateGeolocation(note.id);
app().switchCurrentFolder(app().currentFolder());
}
}
module.exports = Command;

View File

@ -1,35 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
class Command extends BaseCommand {
usage() {
return 'mv <note> [notebook]';
}
description() {
return _('Moves the notes matching <note> to [notebook].');
}
async action(args) {
const pattern = args['note'];
const destination = args['notebook'];
const folder = await Folder.loadByField('title', destination);
if (!folder) throw new Error(_('Cannot find "%s".', destination));
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
for (let i = 0; i < notes.length; i++) {
await Note.moveToFolder(notes[i].id, folder.id);
}
}
}
module.exports = Command;

View File

@ -1,41 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
class Command extends BaseCommand {
usage() {
return 'ren <item> <name>';
}
description() {
return _('Renames the given <item> (note or notebook) to <name>.');
}
async action(args) {
const pattern = args['item'];
const name = args['name'];
const item = await app().loadItem('folderOrNote', pattern);
this.encryptionCheck(item);
if (!item) throw new Error(_('Cannot find "%s".', pattern));
const newItem = {
id: item.id,
title: name,
type_: item.type_,
};
if (item.type_ === BaseModel.TYPE_FOLDER) {
await Folder.save(newItem);
} else {
await Note.save(newItem);
}
}
}
module.exports = Command;

View File

@ -1,40 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseItem = require('lib/models/BaseItem.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const BaseModel = require('lib/BaseModel.js');
const { cliUtils } = require('./cli-utils.js');
class Command extends BaseCommand {
usage() {
return 'rmbook <notebook>';
}
description() {
return _('Deletes the given notebook.');
}
options() {
return [
['-f, --force', _('Deletes the notebook without asking for confirmation.')],
];
}
async action(args) {
const pattern = args['notebook'];
const force = args.options && args.options.force === true;
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
const ok = force ? true : await this.prompt(_('Delete notebook? All notes and sub-notebooks within this notebook will also be deleted.'), { booleanAnswerDefault: 'n' });
if (!ok) return;
await Folder.delete(folder.id);
}
}
module.exports = Command;

View File

@ -1,41 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseItem = require('lib/models/BaseItem.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const BaseModel = require('lib/BaseModel.js');
const { cliUtils } = require('./cli-utils.js');
class Command extends BaseCommand {
usage() {
return 'rmnote <note-pattern>';
}
description() {
return _('Deletes the notes matching <note-pattern>.');
}
options() {
return [
['-f, --force', _('Deletes the notes without asking for confirmation.')],
];
}
async action(args) {
const pattern = args['note-pattern'];
const force = args.options && args.options.force === true;
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
const ok = force ? true : await this.prompt(notes.length > 1 ? _('%d notes match this pattern. Delete them?', notes.length) : _('Delete note?'), { booleanAnswerDefault: 'n' });
if (!ok) return;
let ids = notes.map((n) => n.id);
await Note.batchDelete(ids);
}
}
module.exports = Command;

View File

@ -1,56 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const { sprintf } = require('sprintf-js');
const { time } = require('lib/time-utils.js');
const { uuid } = require('lib/uuid.js');
class Command extends BaseCommand {
usage() {
return 'search <pattern> [notebook]';
}
description() {
return _('Searches for the given <pattern> in all the notes.');
}
compatibleUis() {
return ['gui'];
}
async action(args) {
let pattern = args['pattern'];
let folderTitle = args['notebook'];
let folder = null;
if (folderTitle) {
folder = await Folder.loadByTitle(folderTitle);
if (!folder) throw new Error(_('Cannot find "%s".', folderTitle));
}
const searchId = uuid.create();
this.dispatch({
type: 'SEARCH_ADD',
search: {
id: searchId,
title: pattern,
query_pattern: pattern,
query_folder_id: folder ? folder.id : '',
type_: BaseModel.TYPE_SEARCH,
},
});
this.dispatch({
type: 'SEARCH_SELECT',
id: searchId,
});
}
}
module.exports = Command;

View File

@ -1,51 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const { Database } = require('lib/database.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const BaseItem = require('lib/models/BaseItem.js');
class Command extends BaseCommand {
usage() {
return 'set <note> <name> [value]';
}
description() {
const fields = Note.fields();
const s = [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (f.name === 'id') continue;
s.push(f.name + ' (' + Database.enumName('fieldType', f.type) + ')');
}
return _('Sets the property <name> of the given <note> to the given [value]. Possible properties are:\n\n%s', s.join(', '));
}
async action(args) {
let title = args['note'];
let propName = args['name'];
let propValue = args['value'];
if (!propValue) propValue = '';
let notes = await app().loadItems(BaseModel.TYPE_NOTE, title);
if (!notes.length) throw new Error(_('Cannot find "%s".', title));
for (let i = 0; i < notes.length; i++) {
this.encryptionCheck(notes[i]);
let newNote = {
id: notes[i].id,
type_: notes[i].type_,
};
newNote[propName] = propValue;
await Note.save(newNote);
}
}
}
module.exports = Command;

View File

@ -1,43 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { Database } = require('lib/database.js');
const { app } = require('./app.js');
const Setting = require('lib/models/Setting.js');
const { _ } = require('lib/locale.js');
const { ReportService } = require('lib/services/report.js');
class Command extends BaseCommand {
usage() {
return 'status';
}
description() {
return _('Displays summary about the notes and notebooks.');
}
async action(args) {
let service = new ReportService();
let report = await service.status(Setting.value('sync.target'));
for (let i = 0; i < report.length; i++) {
let section = report[i];
if (i > 0) this.stdout('');
this.stdout('# ' + section.title);
this.stdout('');
for (let n in section.body) {
if (!section.body.hasOwnProperty(n)) continue;
let line = section.body[n];
this.stdout(line);
}
}
app().gui().showConsole();
app().gui().maximizeConsole();
}
}
module.exports = Command;

View File

@ -1,243 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const { OneDriveApiNodeUtils } = require('./onedrive-api-node-utils.js');
const Setting = require('lib/models/Setting.js');
const BaseItem = require('lib/models/BaseItem.js');
const ResourceFetcher = require('lib/services/ResourceFetcher');
const { Synchronizer } = require('lib/synchronizer.js');
const { reg } = require('lib/registry.js');
const { cliUtils } = require('./cli-utils.js');
const md5 = require('md5');
const locker = require('proper-lockfile');
const fs = require('fs-extra');
const SyncTargetRegistry = require('lib/SyncTargetRegistry');
class Command extends BaseCommand {
constructor() {
super();
this.syncTargetId_ = null;
this.releaseLockFn_ = null;
this.oneDriveApiUtils_ = null;
}
usage() {
return 'sync';
}
description() {
return _('Synchronises with remote storage.');
}
options() {
return [
['--target <target>', _('Sync to provided target (defaults to sync.target config value)')],
];
}
static lockFile(filePath) {
return new Promise((resolve, reject) => {
locker.lock(filePath, { stale: 1000 * 60 * 5 }, (error, release) => {
if (error) {
reject(error);
return;
}
resolve(release);
});
});
}
static isLocked(filePath) {
return new Promise((resolve, reject) => {
locker.check(filePath, (error, isLocked) => {
if (error) {
reject(error);
return;
}
resolve(isLocked);
});
});
}
async doAuth() {
const syncTarget = reg.syncTarget(this.syncTargetId_);
const syncTargetMd = SyncTargetRegistry.idToMetadata(this.syncTargetId_);
if (this.syncTargetId_ === 3 || this.syncTargetId_ === 4) { // OneDrive
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils(syncTarget.api());
const auth = await this.oneDriveApiUtils_.oauthDance({
log: (...s) => { return this.stdout(...s); }
});
this.oneDriveApiUtils_ = null;
Setting.setValue('sync.' + this.syncTargetId_ + '.auth', auth ? JSON.stringify(auth) : null);
if (!auth) {
this.stdout(_('Authentication was not completed (did not receive an authentication token).'));
return false;
}
return true;
} else if (syncTargetMd.name === 'dropbox') { // Dropbox
const api = await syncTarget.api();
const loginUrl = api.loginUrl();
this.stdout(_('To allow Joplin to synchronise with Dropbox, please follow the steps below:'));
this.stdout(_('Step 1: Open this URL in your browser to authorise the application:'));
this.stdout(loginUrl);
const authCode = await this.prompt(_('Step 2: Enter the code provided by Dropbox:'), { type: 'string' });
if (!authCode) {
this.stdout(_('Authentication was not completed (did not receive an authentication token).'));
return false;
}
const response = await api.execAuthToken(authCode);
Setting.setValue('sync.' + this.syncTargetId_ + '.auth', response.access_token);
api.setAuthToken(response.access_token);
return true;
}
this.stdout(_('Not authentified with %s. Please provide any missing credentials.', syncTargetMd.label));
return false;
}
cancelAuth() {
if (this.oneDriveApiUtils_) {
this.oneDriveApiUtils_.cancelOAuthDance();
return;
}
}
doingAuth() {
return !!this.oneDriveApiUtils_;
}
async action(args) {
this.releaseLockFn_ = null;
// Lock is unique per profile/database
const lockFilePath = require('os').tmpdir() + '/synclock_' + md5(escape(Setting.value('profileDir'))); // https://github.com/pvorb/node-md5/issues/41
if (!await fs.pathExists(lockFilePath)) await fs.writeFile(lockFilePath, 'synclock');
try {
if (await Command.isLocked(lockFilePath)) throw new Error(_('Synchronisation is already in progress.'));
this.releaseLockFn_ = await Command.lockFile(lockFilePath);
} catch (error) {
if (error.code == 'ELOCKED') {
const msg = _('Lock file is already being hold. If you know that no synchronisation is taking place, you may delete the lock file at "%s" and resume the operation.', error.file);
this.stdout(msg);
return;
}
throw error;
}
const cleanUp = () => {
cliUtils.redrawDone();
if (this.releaseLockFn_) {
this.releaseLockFn_();
this.releaseLockFn_ = null;
}
};
try {
this.syncTargetId_ = Setting.value('sync.target');
if (args.options.target) this.syncTargetId_ = args.options.target;
const syncTarget = reg.syncTarget(this.syncTargetId_);
if (!await syncTarget.isAuthenticated()) {
app().gui().showConsole();
app().gui().maximizeConsole();
const authDone = await this.doAuth();
if (!authDone) return cleanUp();
}
const sync = await syncTarget.synchronizer();
let options = {
onProgress: (report) => {
let lines = Synchronizer.reportToLines(report);
if (lines.length) cliUtils.redraw(lines.join(' '));
},
onMessage: (msg) => {
cliUtils.redrawDone();
this.stdout(msg);
},
};
this.stdout(_('Synchronisation target: %s (%s)', Setting.enumOptionLabel('sync.target', this.syncTargetId_), this.syncTargetId_));
if (!sync) throw new Error(_('Cannot initialize synchroniser.'));
this.stdout(_('Starting synchronisation...'));
const contextKey = 'sync.' + this.syncTargetId_ + '.context';
let context = Setting.value(contextKey);
context = context ? JSON.parse(context) : {};
options.context = context;
try {
let newContext = await sync.start(options);
Setting.setValue(contextKey, JSON.stringify(newContext));
} catch (error) {
if (error.code == 'alreadyStarted') {
this.stdout(error.message);
} else {
throw error;
}
}
// When using the tool in command line mode, the ResourceFetcher service is
// not going to be running in the background, so the resources need to be
// explicitely downloaded below.
if (!app().hasGui()) {
this.stdout(_('Downloading resources...'));
await ResourceFetcher.instance().fetchAll();
await ResourceFetcher.instance().waitForAllFinished();
}
await app().refreshCurrentFolder();
} catch (error) {
cleanUp();
throw error;
}
cleanUp();
}
async cancel() {
if (this.doingAuth()) {
this.cancelAuth();
return;
}
const syncTargetId = this.syncTargetId_ ? this.syncTargetId_ : Setting.value('sync.target');
cliUtils.redrawDone();
this.stdout(_('Cancelling... Please wait.'));
const syncTarget = reg.syncTarget(syncTargetId);
if (await syncTarget.isAuthenticated()) {
const sync = await syncTarget.synchronizer();
if (sync) await sync.cancel();
} else {
if (this.releaseLockFn_) this.releaseLockFn_();
this.releaseLockFn_ = null;
}
this.syncTargetId_ = null;
}
cancellable() {
return true;
}
}
module.exports = Command;

View File

@ -1,86 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const Tag = require('lib/models/Tag.js');
const BaseModel = require('lib/BaseModel.js');
const { time } = require('lib/time-utils.js');
class Command extends BaseCommand {
usage() {
return 'tag <tag-command> [tag] [note]';
}
description() {
return _('<tag-command> can be "add", "remove" or "list" to assign or remove [tag] from [note], or to list the notes associated with [tag]. The command `tag list` can be used to list all the tags (use -l for long option).');
}
options() {
return [
['-l, --long', _('Use long list format. Format is ID, NOTE_COUNT (for notebook), DATE, TODO_CHECKED (for to-dos), TITLE')],
];
}
async action(args) {
let tag = null;
let options = args.options;
if (args.tag) tag = await app().loadItem(BaseModel.TYPE_TAG, args.tag);
let notes = [];
if (args.note) {
notes = await app().loadItems(BaseModel.TYPE_NOTE, args.note);
}
const command = args['tag-command'];
if (command == 'remove' && !tag) throw new Error(_('Cannot find "%s".', args.tag));
if (command == 'add') {
if (!notes.length) throw new Error(_('Cannot find "%s".', args.note));
if (!tag) tag = await Tag.save({ title: args.tag }, { userSideValidation: true });
for (let i = 0; i < notes.length; i++) {
await Tag.addNote(tag.id, notes[i].id);
}
} else if (command == 'remove') {
if (!tag) throw new Error(_('Cannot find "%s".', args.tag));
if (!notes.length) throw new Error(_('Cannot find "%s".', args.note));
for (let i = 0; i < notes.length; i++) {
await Tag.removeNote(tag.id, notes[i].id);
}
} else if (command == 'list') {
if (tag) {
let notes = await Tag.notes(tag.id);
notes.map((note) => {
let line = '';
if (options.long) {
line += BaseModel.shortId(note.id);
line += ' ';
line += time.formatMsToLocal(note.user_updated_time);
line += ' ';
}
if (note.is_todo) {
line += '[';
if (note.todo_completed) {
line += 'X';
} else {
line += ' ';
}
line += '] ';
} else {
line += ' ';
}
line += note.title;
this.stdout(line);
});
} else {
let tags = await Tag.all();
tags.map((tag) => { this.stdout(tag.title); });
}
} else {
throw new Error(_('Invalid command: "%s"', command));
}
}
}
module.exports = Command;

View File

@ -1,50 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const { time } = require('lib/time-utils.js');
class Command extends BaseCommand {
usage() {
return 'todo <todo-command> <note-pattern>';
}
description() {
return _('<todo-command> can either be "toggle" or "clear". Use "toggle" to toggle the given to-do between completed and uncompleted state (If the target is a regular note it will be converted to a to-do). Use "clear" to convert the to-do back to a regular note.');
}
async action(args) {
const action = args['todo-command'];
const pattern = args['note-pattern'];
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
this.encryptionCheck(note);
let toSave = {
id: note.id,
};
if (action == 'toggle') {
if (!note.is_todo) {
toSave = Note.toggleIsTodo(note);
} else {
toSave.todo_completed = note.todo_completed ? 0 : time.unixMs();
}
} else if (action == 'clear') {
toSave.is_todo = 0;
}
await Note.save(toSave);
}
}
}
module.exports = Command;

View File

@ -1,27 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const { time } = require('lib/time-utils.js');
const CommandDone = require('./command-done.js');
class Command extends BaseCommand {
usage() {
return 'undone <note>';
}
description() {
return _('Marks a to-do as non-completed.');
}
async action(args) {
await CommandDone.handleAction(this, args, false);
}
}
module.exports = Command;

View File

@ -1,33 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('lib/locale.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
class Command extends BaseCommand {
usage() {
return 'use <notebook>';
}
description() {
return _('Switches to [notebook] - all further operations will happen within this notebook.');
}
autocomplete() {
return { data: autocompleteFolders };
}
compatibleUis() {
return ['cli'];
}
async action(args) {
let folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
app().switchCurrentFolder(folder);
}
}
module.exports = Command;

View File

@ -1,22 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const Setting = require('lib/models/Setting.js');
const { _ } = require('lib/locale.js');
class Command extends BaseCommand {
usage() {
return 'version';
}
description() {
return _('Displays version information');
}
async action(args) {
const p = require('./package.json');
this.stdout(_('%s %s (%s)', p.name, p.version, Setting.value('env')));
}
}
module.exports = Command;

File diff suppressed because one or more lines are too long

View File

@ -1,52 +0,0 @@
const TextWidget = require('tkwidgets/TextWidget.js');
class ConsoleWidget extends TextWidget {
constructor() {
super();
this.lines_ = [];
this.updateText_ = false;
this.markdownRendering = false;
this.stickToBottom = true;
this.maxLines_ = 1000;
}
get name() {
return 'console';
}
get lastLine() {
return this.lines_.length ? this.lines_[this.lines_.length-1] : '';
}
addLine(line) {
this.lines_.push(line);
this.updateText_ = true;
this.invalidate();
}
onFocus() {
this.stickToBottom = false;
super.onFocus();
}
onBlur() {
this.stickToBottom = true;
super.onBlur();
}
render() {
if (this.updateText_) {
if (this.lines_.length > this.maxLines_) {
this.lines_.splice(0, this.lines_.length - this.maxLines_);
}
this.text = this.lines_.join("\n");
this.updateText_ = false;
}
super.render();
}
}
module.exports = ConsoleWidget;

View File

@ -1,194 +0,0 @@
const Folder = require('lib/models/Folder.js');
const Tag = require('lib/models/Tag.js');
const BaseModel = require('lib/BaseModel.js');
const ListWidget = require('tkwidgets/ListWidget.js');
const _ = require('lib/locale.js')._;
class FolderListWidget extends ListWidget {
constructor() {
super();
this.tags_ = [];
this.folders_ = [];
this.searches_ = [];
this.selectedFolderId_ = null;
this.selectedTagId_ = null;
this.selectedSearchId_ = null;
this.notesParentType_ = 'Folder';
this.updateIndexFromSelectedFolderId_ = false;
this.updateItems_ = false;
this.trimItemTitle = false;
this.itemRenderer = (item) => {
let output = [];
if (item === '-') {
output.push('-'.repeat(this.innerWidth));
} else if (item.type_ === Folder.modelType()) {
output.push(' '.repeat(this.folderDepth(this.folders, item.id)) + Folder.displayTitle(item));
} else if (item.type_ === Tag.modelType()) {
output.push('[' + Folder.displayTitle(item) + ']');
} else if (item.type_ === BaseModel.TYPE_SEARCH) {
output.push(_('Search:'));
output.push(item.title);
}
return output.join(' ');
};
}
folderDepth(folders, folderId) {
let output = 0;
while (true) {
const folder = BaseModel.byId(folders, folderId);
if (!folder.parent_id) return output;
output++;
folderId = folder.parent_id;
}
throw new Error('unreachable');
}
get selectedFolderId() {
return this.selectedFolderId_;
}
set selectedFolderId(v) {
this.selectedFolderId_ = v;
this.updateIndexFromSelectedItemId()
this.invalidate();
}
get selectedSearchId() {
return this.selectedSearchId_;
}
set selectedSearchId(v) {
this.selectedSearchId_ = v;
this.updateIndexFromSelectedItemId()
this.invalidate();
}
get selectedTagId() {
return this.selectedTagId_;
}
set selectedTagId(v) {
this.selectedTagId_ = v;
this.updateIndexFromSelectedItemId()
this.invalidate();
}
get notesParentType() {
return this.notesParentType_;
}
set notesParentType(v) {
this.notesParentType_ = v;
this.updateIndexFromSelectedItemId()
this.invalidate();
}
get searches() {
return this.searches_;
}
set searches(v) {
this.searches_ = v;
this.updateItems_ = true;
this.updateIndexFromSelectedItemId()
this.invalidate();
}
get tags() {
return this.tags_;
}
set tags(v) {
this.tags_ = v;
this.updateItems_ = true;
this.updateIndexFromSelectedItemId()
this.invalidate();
}
get folders() {
return this.folders_;
}
set folders(v) {
this.folders_ = v;
this.updateItems_ = true;
this.updateIndexFromSelectedItemId()
this.invalidate();
}
folderHasChildren_(folders, folderId) {
for (let i = 0; i < folders.length; i++) {
let folder = folders[i];
if (folder.parent_id === folderId) return true;
}
return false;
}
render() {
if (this.updateItems_) {
this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId);
const wasSelectedItemId = this.selectedJoplinItemId;
const previousParentType = this.notesParentType;
let newItems = [];
const orderFolders = (parentId) => {
for (let i = 0; i < this.folders.length; i++) {
const f = this.folders[i];
const folderParentId = f.parent_id ? f.parent_id : '';
if (folderParentId === parentId) {
newItems.push(f);
if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id);
}
}
}
orderFolders('');
if (this.tags.length) {
if (newItems.length) newItems.push('-');
newItems = newItems.concat(this.tags);
}
if (this.searches.length) {
if (newItems.length) newItems.push('-');
newItems = newItems.concat(this.searches);
}
this.items = newItems;
this.notesParentType = previousParentType;
this.updateIndexFromSelectedItemId(wasSelectedItemId)
this.updateItems_ = false;
}
super.render();
}
get selectedJoplinItemId() {
if (!this.notesParentType) return '';
if (this.notesParentType === 'Folder') return this.selectedFolderId;
if (this.notesParentType === 'Tag') return this.selectedTagId;
if (this.notesParentType === 'Search') return this.selectedSearchId;
throw new Error('Unknown parent type: ' + this.notesParentType);
}
get selectedJoplinItem() {
const id = this.selectedJoplinItemId;
const index = this.itemIndexByKey('id', id);
return this.itemAt(index);
}
updateIndexFromSelectedItemId(itemId = null) {
if (itemId === null) itemId = this.selectedJoplinItemId;
const index = this.itemIndexByKey('id', itemId);
this.currentIndex = index >= 0 ? index : 0;
}
}
module.exports = FolderListWidget;

View File

@ -1,38 +0,0 @@
const Note = require('lib/models/Note.js');
const ListWidget = require('tkwidgets/ListWidget.js');
class NoteListWidget extends ListWidget {
constructor() {
super();
this.selectedNoteId_ = 0;
this.updateIndexFromSelectedNoteId_ = false;
this.itemRenderer = (note) => {
let label = Note.displayTitle(note); // + ' ' + note.id;
if (note.is_todo) {
label = '[' + (note.todo_completed ? 'X' : ' ') + '] ' + label;
}
return label;
};
}
set selectedNoteId(v) {
this.updateIndexFromSelectedNoteId_ = true;
this.selectedNoteId_ = v;
}
render() {
if (this.updateIndexFromSelectedNoteId_) {
const index = this.itemIndexByKey('id', this.selectedNoteId_);
this.currentIndex = index >= 0 ? index : 0;
this.updateIndexFromSelectedNoteId_ = false;
}
super.render();
}
}
module.exports = NoteListWidget;

View File

@ -1,36 +0,0 @@
const Note = require('lib/models/Note.js');
const TextWidget = require('tkwidgets/TextWidget.js');
class NoteMetadataWidget extends TextWidget {
constructor() {
super();
this.noteId_ = 0;
this.note_ = null;
}
get noteId() {
return this.noteId_;
}
set noteId(v) {
// If this is called it means either the note ID has changed OR
// the note content has changed, so we always set note_ to null
// so that it can be reloaded in onWillRender().
this.noteId_ = v;
this.note_ = null;
this.invalidate();
}
async onWillRender() {
if (!this.visible) return;
if (!this.note_ && this.noteId_) {
this.note_ = await Note.load(this.noteId_);
this.text = this.note_ ? await Note.minimalSerializeForDisplay(this.note_) : '';
}
}
}
module.exports = NoteMetadataWidget;

View File

@ -1,65 +0,0 @@
const Note = require('lib/models/Note.js');
const TextWidget = require('tkwidgets/TextWidget.js');
const { _ } = require('lib/locale.js');
class NoteWidget extends TextWidget {
constructor() {
super();
this.noteId_ = 0;
this.note_ = null;
this.notes_ = [];
this.lastLoadedNoteId_ = null;
}
get notes() {
return this.notes_;
}
set notes(v) {
// If the note collection has changed it means the current note might
// have changed or has been deleted, so refresh the note.
this.notes_ = v;
this.reloadNote();
}
get noteId() {
return this.noteId_;
}
set noteId(v) {
this.noteId_ = v;
this.note_ = null;
this.reloadNote();
}
welcomeText() {
return _('Welcome to Joplin!\n\nType `:help shortcuts` for the list of keyboard shortcuts, or just `:help` for usage information.\n\nFor example, to create a notebook press `mb`; to create a note press `mn`.');
}
reloadNote() {
if (!this.noteId_ && !this.notes.length) {
this.text = this.welcomeText();
this.scrollTop = 0;
} else if (this.noteId_) {
this.doAsync('loadNote', async () => {
this.note_ = await Note.load(this.noteId_);
if (this.note_ && this.note_.encryption_applied) {
this.text = _('One or more items are currently encrypted and you may need to supply a master password. To do so please type `e2ee decrypt`. If you have already supplied the password, the encrypted items are being decrypted in the background and will be available soon.');
} else {
this.text = this.note_ ? this.note_.title + "\n\n" + this.note_.body : '';
}
if (this.lastLoadedNoteId_ !== this.noteId_) this.scrollTop = 0;
this.lastLoadedNoteId_ = this.noteId_;
});
} else {
this.text = '';
this.scrollTop = 0;
}
}
}
module.exports = NoteWidget;

View File

@ -1,171 +0,0 @@
const BaseWidget = require('tkwidgets/BaseWidget.js');
const chalk = require('chalk');
const termutils = require('tkwidgets/framework/termutils.js');
const stripAnsi = require('strip-ansi');
const { handleAutocompletion } = require('../autocompletion.js');
class StatusBarWidget extends BaseWidget {
constructor() {
super();
this.promptState_ = null;
this.inputEventEmitter_ = null;
this.history_ = [];
this.items_ = [];
}
get name() {
return 'statusBar';
}
get canHaveFocus() {
return false;
}
setItemAt(index, text) {
this.items_[index] = stripAnsi(text).trim();
this.invalidate();
}
async prompt(initialText = '', promptString = null, options = null) {
if (this.promptState_) throw new Error('Another prompt already active');
if (promptString === null) promptString = ':';
if (options === null) options = {};
this.root.globalDisableKeyboard(this);
this.promptState_ = {
promise: null,
initialText: stripAnsi(initialText),
promptString: stripAnsi(promptString),
};
if ('cursorPosition' in options) this.promptState_.cursorPosition = options.cursorPosition;
if ('secure' in options) this.promptState_.secure = options.secure;
this.promptState_.promise = new Promise((resolve, reject) => {
this.promptState_.resolve = resolve;
this.promptState_.reject = reject;
});
this.invalidate();
return this.promptState_.promise;
}
get promptActive() {
return !!this.promptState_;
}
get history() {
return this.history_;
}
resetCursor() {
if (!this.promptActive) return;
if (!this.inputEventEmitter_) return;
this.inputEventEmitter_.redraw();
this.inputEventEmitter_.rebase(this.absoluteInnerX + termutils.textLength(this.promptState_.promptString), this.absoluteInnerY);
this.term.moveTo(this.absoluteInnerX + termutils.textLength(this.promptState_.promptString) + this.inputEventEmitter_.getInput().length, this.absoluteInnerY);
}
render() {
super.render();
const doSaveCursor = !this.promptActive;
if (doSaveCursor) this.term.saveCursor();
this.innerClear();
// Note:
// On Ubuntu, bgBlueBright looks too bright which makes the white text illegible
// On Windows, bgBlueBright is fine and looks dark enough (Windows is probably in the wrong though)
// For now, just don't use any colour at all.
//const textStyle = this.promptActive ? (s) => s : chalk.bgBlueBright.white;
//const textStyle = (s) => s;
const textStyle = this.promptActive ? (s) => s : chalk.gray;
this.term.drawHLine(this.absoluteInnerX, this.absoluteInnerY, this.innerWidth, textStyle(' '));
this.term.moveTo(this.absoluteInnerX, this.absoluteInnerY);
if (this.promptActive) {
this.term.write(textStyle(this.promptState_.promptString));
if (this.inputEventEmitter_) {
// inputField is already waiting for input so in that case just make
// sure that the cursor is at the right position and exit.
this.resetCursor();
return;
}
this.term.showCursor(true);
const isSecurePrompt = !!this.promptState_.secure;
let options = {
cancelable: true,
history: this.history,
default: this.promptState_.initialText,
autoComplete: handleAutocompletion,
autoCompleteHint : true,
autoCompleteMenu : true,
};
if ('cursorPosition' in this.promptState_) options.cursorPosition = this.promptState_.cursorPosition;
if (isSecurePrompt) options.echoChar = true;
this.inputEventEmitter_ = this.term.inputField(options, (error, input) => {
let resolveResult = null;
const resolveFn = this.promptState_.resolve;
if (error) {
this.logger().error('StatusBar: inputField error:', error);
} else {
if (input === undefined) {
// User cancel
} else {
resolveResult = input ? input.trim() : input;
// Add the command to history but only if it's longer than one character.
// Below that it's usually an answer like "y"/"n", etc.
const isConfigPassword = input.indexOf('config ') >= 0 && input.indexOf('password') >= 0;
if (!isSecurePrompt && input && input.length > 1 && !isConfigPassword) this.history_.push(input);
}
}
// If the inputField spans several lines invalidate the root so that
// the interface is relayouted.
if (termutils.textLength(this.promptState_.promptString) + termutils.textLength(input) >= this.innerWidth - 5) {
this.root.invalidate();
}
this.inputEventEmitter_ = null;
this.term.showCursor(false);
this.promptState_ = null;
this.root.globalEnableKeyboard(this);
this.invalidate();
// Only callback once everything has been cleaned up and reset
resolveFn(resolveResult);
});
} else {
for (let i = 0; i < this.items_.length; i++) {
const s = this.items_[i].substr(0, this.innerWidth - 1);
this.term.write(textStyle(s));
}
}
if (doSaveCursor) this.term.restoreCursor();
}
}
module.exports = StatusBarWidget;

View File

@ -1,107 +0,0 @@
const fs = require('fs-extra');
const { wrap } = require('lib/string-utils.js');
const Setting = require('lib/models/Setting.js');
const { fileExtension, basename, dirname } = require('lib/path-utils.js');
const { _, setLocale, languageCode } = require('lib/locale.js');
const rootDir = dirname(dirname(__dirname));
const MAX_WIDTH = 78;
const INDENT = ' ';
function renderTwoColumnData(options, baseIndent, width) {
let output = [];
const optionColWidth = getOptionColWidth(options);
for (let i = 0; i < options.length; i++) {
let option = options[i];
const flag = option[0];
const indent = baseIndent + INDENT + ' '.repeat(optionColWidth + 2);
let r = wrap(option[1], indent, width);
r = r.substr(flag.length + (baseIndent + INDENT).length);
r = baseIndent + INDENT + flag + r;
output.push(r);
}
return output.join("\n");
}
function renderCommandHelp(cmd, width = null) {
if (width === null) width = MAX_WIDTH;
const baseIndent = '';
let output = [];
output.push(baseIndent + cmd.usage());
output.push('');
output.push(wrap(cmd.description(), baseIndent + INDENT, width));
const optionString = renderTwoColumnData(cmd.options(), baseIndent, width);
if (optionString) {
output.push('');
output.push(optionString);
}
if (cmd.name() === 'config') {
const renderMetadata = (md) => {
let desc = [];
if (md.label) {
let label = md.label();
if (label.length && label[label.length - 1] !== '.') label += '.';
desc.push(label);
}
const description = Setting.keyDescription(md.key, 'cli');
if (description) desc.push(description);
desc.push(_('Type: %s.', md.isEnum ? _('Enum') : Setting.typeToString(md.type)));
if (md.isEnum) desc.push(_('Possible values: %s.', Setting.enumOptionsDoc(md.key, '%s (%s)')));
let defaultString = null;
if ('value' in md) {
if (md.type === Setting.TYPE_STRING) {
defaultString = md.value ? '"' + md.value + '"' : null;
} else if (md.type === Setting.TYPE_INT) {
defaultString = (md.value ? md.value : 0).toString();
} else if (md.type === Setting.TYPE_BOOL) {
defaultString = (md.value === true ? 'true' : 'false');
}
}
if (defaultString !== null) desc.push(_('Default: %s', defaultString));
return [md.key, desc.join("\n")];
};
output.push('');
output.push(_('Possible keys/values:'));
output.push('');
let keysValues = [];
const keys = Setting.keys(true, 'cli');
for (let i = 0; i < keys.length; i++) {
if (keysValues.length) keysValues.push(['','']);
const md = Setting.settingMetadata(keys[i]);
if (!md.label) continue;
keysValues.push(renderMetadata(md));
}
output.push(renderTwoColumnData(keysValues, baseIndent, width));
}
return output.join("\n");
}
function getOptionColWidth(options) {
let output = 0;
for (let j = 0; j < options.length; j++) {
const option = options[j];
if (option[0].length > output) output = option[0].length;
}
return output;
}
module.exports = { renderCommandHelp };

View File

@ -1,61 +0,0 @@
const ArrayUtils = {};
ArrayUtils.unique = function(array) {
return array.filter(function(elem, index, self) {
return index === self.indexOf(elem);
});
}
ArrayUtils.removeElement = function(array, element) {
const index = array.indexOf(element);
if (index < 0) return array;
array.splice(index, 1);
return array;
}
// https://stackoverflow.com/a/10264318/561309
ArrayUtils.binarySearch = function(items, value) {
var startIndex = 0,
stopIndex = items.length - 1,
middle = Math.floor((stopIndex + startIndex)/2);
while(items[middle] != value && startIndex < stopIndex){
//adjust search area
if (value < items[middle]){
stopIndex = middle - 1;
} else if (value > items[middle]){
startIndex = middle + 1;
}
//recalculate middle
middle = Math.floor((stopIndex + startIndex)/2);
}
//make sure it's the right value
return (items[middle] != value) ? -1 : middle;
}
ArrayUtils.findByKey = function(array, key, value) {
for (let i = 0; i < array.length; i++) {
const o = array[i];
if (typeof o !== 'object') continue;
if (o[key] === value) return o;
}
return null;
}
ArrayUtils.contentEquals = function(array1, array2) {
if (array1 === array2) return true;
if (!array1.length && !array2.length) return true;
if (array1.length !== array2.length) return false;
for (let i = 0; i < array1.length; i++) {
const a1 = array1[i];
if (array2.indexOf(a1) < 0) return false;
}
return true;
}
module.exports = ArrayUtils;

View File

@ -1,625 +0,0 @@
const { createStore, applyMiddleware } = require('redux');
const { reducer, defaultState, stateUtils } = require('lib/reducer.js');
const { JoplinDatabase } = require('lib/joplin-database.js');
const { Database } = require('lib/database.js');
const { FoldersScreenUtils } = require('lib/folders-screen-utils.js');
const { DatabaseDriverNode } = require('lib/database-driver-node.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const BaseItem = require('lib/models/BaseItem.js');
const Note = require('lib/models/Note.js');
const Tag = require('lib/models/Tag.js');
const Setting = require('lib/models/Setting.js');
const { Logger } = require('lib/logger.js');
const { splitCommandString } = require('lib/string-utils.js');
const { sprintf } = require('sprintf-js');
const { reg } = require('lib/registry.js');
const { time } = require('lib/time-utils.js');
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { fileExtension } = require('lib/path-utils.js');
const { shim } = require('lib/shim.js');
const { _, setLocale, defaultLocale, closestSupportedLocale } = require('lib/locale.js');
const reduxSharedMiddleware = require('lib/components/shared/reduxSharedMiddleware');
const os = require('os');
const fs = require('fs-extra');
const JoplinError = require('lib/JoplinError');
const EventEmitter = require('events');
const syswidecas = require('syswide-cas');
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js');
const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js');
const SyncTargetWebDAV = require('lib/SyncTargetWebDAV.js');
const SyncTargetDropbox = require('lib/SyncTargetDropbox.js');
const EncryptionService = require('lib/services/EncryptionService');
const ResourceFetcher = require('lib/services/ResourceFetcher');
const SearchEngineUtils = require('lib/services/SearchEngineUtils');
const RevisionService = require('lib/services/RevisionService');
const DecryptionWorker = require('lib/services/DecryptionWorker');
const BaseService = require('lib/services/BaseService');
const SearchEngine = require('lib/services/SearchEngine');
const MigrationService = require('lib/services/MigrationService');
SyncTargetRegistry.addClass(SyncTargetFilesystem);
SyncTargetRegistry.addClass(SyncTargetOneDrive);
SyncTargetRegistry.addClass(SyncTargetOneDriveDev);
SyncTargetRegistry.addClass(SyncTargetNextcloud);
SyncTargetRegistry.addClass(SyncTargetWebDAV);
SyncTargetRegistry.addClass(SyncTargetDropbox);
class BaseApplication {
constructor() {
this.logger_ = new Logger();
this.dbLogger_ = new Logger();
this.eventEmitter_ = new EventEmitter();
// Note: this is basically a cache of state.selectedFolderId. It should *only*
// be derived from the state and not set directly since that would make the
// state and UI out of sync.
this.currentFolder_ = null;
}
logger() {
return this.logger_;
}
store() {
return this.store_;
}
currentFolder() {
return this.currentFolder_;
}
async refreshCurrentFolder() {
let newFolder = null;
if (this.currentFolder_) newFolder = await Folder.load(this.currentFolder_.id);
if (!newFolder) newFolder = await Folder.defaultFolder();
this.switchCurrentFolder(newFolder);
}
switchCurrentFolder(folder) {
if (!this.hasGui()) {
this.currentFolder_ = Object.assign({}, folder);
Setting.setValue('activeFolderId', folder ? folder.id : '');
} else {
this.dispatch({
type: 'FOLDER_SELECT',
id: folder ? folder.id : '',
});
}
}
// Handles the initial flags passed to main script and
// returns the remaining args.
async handleStartFlags_(argv, setDefaults = true) {
let matched = {};
argv = argv.slice(0);
argv.splice(0, 2); // First arguments are the node executable, and the node JS file
while (argv.length) {
let arg = argv[0];
let nextArg = argv.length >= 2 ? argv[1] : null;
if (arg == '--profile') {
if (!nextArg) throw new JoplinError(_('Usage: %s', '--profile <dir-path>'), 'flagError');
matched.profileDir = nextArg;
argv.splice(0, 2);
continue;
}
if (arg == '--no-welcome') {
matched.welcomeDisabled = true;
argv.splice(0, 1);
continue;
}
if (arg == '--env') {
if (!nextArg) throw new JoplinError(_('Usage: %s', '--env <dev|prod>'), 'flagError');
matched.env = nextArg;
argv.splice(0, 2);
continue;
}
if (arg == '--is-demo') {
Setting.setConstant('isDemo', true);
argv.splice(0, 1);
continue;
}
if (arg == '--open-dev-tools') {
Setting.setConstant('openDevTools', true);
argv.splice(0, 1);
continue;
}
if (arg == '--update-geolocation-disabled') {
Note.updateGeolocationEnabled_ = false;
argv.splice(0, 1);
continue;
}
if (arg == '--stack-trace-enabled') {
this.showStackTraces_ = true;
argv.splice(0, 1);
continue;
}
if (arg == '--log-level') {
if (!nextArg) throw new JoplinError(_('Usage: %s', '--log-level <none|error|warn|info|debug>'), 'flagError');
matched.logLevel = Logger.levelStringToId(nextArg);
argv.splice(0, 2);
continue;
}
if (arg.indexOf('-psn') === 0) {
// Some weird flag passed by macOS - can be ignored.
// https://github.com/laurent22/joplin/issues/480
// https://stackoverflow.com/questions/10242115
argv.splice(0, 1);
continue;
}
if (arg === '--enable-logging') {
// Electron-specific flag used for debugging - ignore it
argv.splice(0, 1);
continue;
}
if (arg.length && arg[0] == '-') {
throw new JoplinError(_('Unknown flag: %s', arg), 'flagError');
} else {
break;
}
}
if (setDefaults) {
if (!matched.logLevel) matched.logLevel = Logger.LEVEL_INFO;
if (!matched.env) matched.env = 'prod';
}
return {
matched: matched,
argv: argv,
};
}
on(eventName, callback) {
return this.eventEmitter_.on(eventName, callback);
}
async exit(code = 0) {
await Setting.saveAll();
process.exit(code);
}
async refreshNotes(state, useSelectedNoteId = false) {
let parentType = state.notesParentType;
let parentId = null;
if (parentType === 'Folder') {
parentId = state.selectedFolderId;
parentType = BaseModel.TYPE_FOLDER;
} else if (parentType === 'Tag') {
parentId = state.selectedTagId;
parentType = BaseModel.TYPE_TAG;
} else if (parentType === 'Search') {
parentId = state.selectedSearchId;
parentType = BaseModel.TYPE_SEARCH;
}
this.logger().debug('Refreshing notes:', parentType, parentId);
let options = {
order: stateUtils.notesOrder(state.settings),
uncompletedTodosOnTop: Setting.value('uncompletedTodosOnTop'),
showCompletedTodos: Setting.value('showCompletedTodos'),
caseInsensitive: true,
};
const source = JSON.stringify({
options: options,
parentId: parentId,
});
let notes = [];
if (parentId) {
if (parentType === Folder.modelType()) {
notes = await Note.previews(parentId, options);
} else if (parentType === Tag.modelType()) {
notes = await Tag.notes(parentId, options);
} else if (parentType === BaseModel.TYPE_SEARCH) {
const search = BaseModel.byId(state.searches, parentId);
notes = await SearchEngineUtils.notesForQuery(search.query_pattern);
}
}
this.store().dispatch({
type: 'NOTE_UPDATE_ALL',
notes: notes,
notesSource: source,
});
if (useSelectedNoteId) {
this.store().dispatch({
type: 'NOTE_SELECT',
id: state.selectedNoteIds && state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
});
} else {
const lastSelectedNoteIds = stateUtils.lastSelectedNoteIds(state);
const foundIds = [];
for (let i = 0; i < lastSelectedNoteIds.length; i++) {
const noteId = lastSelectedNoteIds[i];
let found = false;
for (let j = 0; j < notes.length; j++) {
if (notes[j].id === noteId) {
found = true;
break;
}
}
if (found) foundIds.push(noteId);
}
let selectedNoteId = null;
if (foundIds.length) {
selectedNoteId = foundIds[0];
} else {
selectedNoteId = notes.length ? notes[0].id : null;
}
this.store().dispatch({
type: 'NOTE_SELECT',
id: selectedNoteId,
});
}
}
reducerActionToString(action) {
let o = [action.type];
if ('id' in action) o.push(action.id);
if ('noteId' in action) o.push(action.noteId);
if ('folderId' in action) o.push(action.folderId);
if ('tagId' in action) o.push(action.tagId);
if ('tag' in action) o.push(action.tag.id);
if ('folder' in action) o.push(action.folder.id);
if ('notesSource' in action) o.push(JSON.stringify(action.notesSource));
return o.join(', ');
}
hasGui() {
return false;
}
uiType() {
return this.hasGui() ? 'gui' : 'cli';
}
generalMiddlewareFn() {
const middleware = store => next => (action) => {
return this.generalMiddleware(store, next, action);
}
return middleware;
}
async generalMiddleware(store, next, action) {
this.logger().debug('Reducer action', this.reducerActionToString(action));
const result = next(action);
const newState = store.getState();
let refreshNotes = false;
let refreshFolders = false;
// let refreshTags = false;
let refreshNotesUseSelectedNoteId = false;
await reduxSharedMiddleware(store, next, action);
if (this.hasGui() && ["NOTE_UPDATE_ONE", "NOTE_DELETE", "FOLDER_UPDATE_ONE", "FOLDER_DELETE"].indexOf(action.type) >= 0) {
if (!await reg.syncTarget().syncStarted()) reg.scheduleSync(30 * 1000, { syncSteps: ["update_remote", "delete_remote"] });
SearchEngine.instance().scheduleSyncTables();
}
// Don't add FOLDER_UPDATE_ALL as refreshFolders() is calling it too, which
// would cause the sidebar to refresh all the time.
if (this.hasGui() && ["FOLDER_UPDATE_ONE"].indexOf(action.type) >= 0) {
refreshFolders = true;
}
if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE' || action.type === 'FOLDER_AND_NOTE_SELECT' || (action.type === 'SEARCH_UPDATE' && newState.notesParentType === 'Folder')) {
Setting.setValue('activeFolderId', newState.selectedFolderId);
this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null;
refreshNotes = true;
if (action.type === 'FOLDER_AND_NOTE_SELECT') refreshNotesUseSelectedNoteId = true;
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop') || action.type == 'SETTING_UPDATE_ALL')) {
refreshNotes = true;
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'showCompletedTodos') || action.type == 'SETTING_UPDATE_ALL')) {
refreshNotes = true;
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key.indexOf('notes.sortOrder') === 0) || action.type == 'SETTING_UPDATE_ALL')) {
refreshNotes = true;
}
if (action.type == 'TAG_SELECT' || action.type === 'TAG_DELETE') {
refreshNotes = true;
}
if (action.type == 'SEARCH_SELECT' || action.type === 'SEARCH_DELETE') {
refreshNotes = true;
}
// if (action.type == 'NOTE_DELETE') {
// refreshTags = true;
// }
if (refreshNotes) {
await this.refreshNotes(newState, refreshNotesUseSelectedNoteId);
}
// if (refreshTags) {
// this.dispatch({
// type: 'TAG_UPDATE_ALL',
// items: await Tag.allWithNotes(),
// });
// }
if ((action.type == 'SETTING_UPDATE_ONE' && (action.key == 'dateFormat' || action.key == 'timeFormat')) || (action.type == 'SETTING_UPDATE_ALL')) {
time.setDateFormat(Setting.value('dateFormat'));
time.setTimeFormat(Setting.value('timeFormat'));
}
if ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'net.ignoreTlsErrors') || (action.type == 'SETTING_UPDATE_ALL')) {
// https://stackoverflow.com/questions/20082893/unable-to-verify-leaf-signature
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = Setting.value('net.ignoreTlsErrors') ? '0' : '1';
}
if ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'net.customCertificates') || (action.type == 'SETTING_UPDATE_ALL')) {
const caPaths = Setting.value('net.customCertificates').split(',');
for (let i = 0; i < caPaths.length; i++) {
const f = caPaths[i].trim();
if (!f) continue;
syswidecas.addCAs(f);
}
}
if ((action.type == 'SETTING_UPDATE_ONE' && (action.key.indexOf('encryption.') === 0)) || (action.type == 'SETTING_UPDATE_ALL')) {
if (this.hasGui()) {
await EncryptionService.instance().loadMasterKeysFromSettings();
DecryptionWorker.instance().scheduleStart();
const loadedMasterKeyIds = EncryptionService.instance().loadedMasterKeyIds();
this.dispatch({
type: 'MASTERKEY_REMOVE_NOT_LOADED',
ids: loadedMasterKeyIds,
});
// Schedule a sync operation so that items that need to be encrypted
// are sent to sync target.
reg.scheduleSync();
}
}
if (action.type === 'NOTE_UPDATE_ONE') {
refreshFolders = true;
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key.indexOf('folders.sortOrder') === 0) || action.type == 'SETTING_UPDATE_ALL')) {
refreshFolders = 'now';
}
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'sync.interval' || action.type == 'SETTING_UPDATE_ALL') {
reg.setupRecurrentSync();
}
if (this.hasGui() && action.type === 'SYNC_GOT_ENCRYPTED_ITEM') {
DecryptionWorker.instance().scheduleStart();
}
if (this.hasGui() && action.type === 'SYNC_CREATED_RESOURCE') {
ResourceFetcher.instance().queueDownload(action.id);
}
if (refreshFolders) {
if (refreshFolders === 'now') {
await FoldersScreenUtils.refreshFolders();
} else {
await FoldersScreenUtils.scheduleRefreshFolders();
}
}
return result;
}
dispatch(action) {
if (this.store()) return this.store().dispatch(action);
}
reducer(state = defaultState, action) {
return reducer(state, action);
}
initRedux() {
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn()));
BaseModel.dispatch = this.store().dispatch;
FoldersScreenUtils.dispatch = this.store().dispatch;
reg.dispatch = this.store().dispatch;
BaseSyncTarget.dispatch = this.store().dispatch;
DecryptionWorker.instance().dispatch = this.store().dispatch;
ResourceFetcher.instance().dispatch = this.store().dispatch;
}
async readFlagsFromFile(flagPath) {
if (!fs.existsSync(flagPath)) return {};
let flagContent = fs.readFileSync(flagPath, 'utf8');
if (!flagContent) return {};
flagContent = flagContent.trim();
let flags = splitCommandString(flagContent);
flags.splice(0, 0, 'cmd');
flags.splice(0, 0, 'node');
flags = await this.handleStartFlags_(flags, false);
return flags.matched;
}
determineProfileDir(initArgs) {
if (initArgs.profileDir) return initArgs.profileDir;
if (process && process.env && process.env.PORTABLE_EXECUTABLE_DIR) return process.env.PORTABLE_EXECUTABLE_DIR + '/JoplinProfile';
return os.homedir() + '/.config/' + Setting.value('appName');
}
async testing() {
const markdownUtils = require('lib/markdownUtils');
const ClipperServer = require('lib/ClipperServer');
const server = new ClipperServer();
const HtmlToMd = require('lib/HtmlToMd');
const service = new HtmlToMd();
const html = await shim.fsDriver().readFile('/mnt/d/test.html');
let markdown = service.parse(html, { baseUrl: 'https://duckduckgo.com/' });
console.info(markdown);
console.info('--------------------------------------------------');
const imageUrls = markdownUtils.extractImageUrls(markdown);
let result = await server.downloadImages_(imageUrls);
result = await server.createResourcesFromPaths_(result);
console.info(result);
markdown = server.replaceImageUrlsByResources_(markdown, result);
console.info('--------------------------------------------------');
console.info(markdown);
console.info('--------------------------------------------------');
}
async start(argv) {
let startFlags = await this.handleStartFlags_(argv);
argv = startFlags.argv;
let initArgs = startFlags.matched;
if (argv.length) this.showPromptString_ = false;
let appName = initArgs.env == 'dev' ? 'joplindev' : 'joplin';
if (Setting.value('appId').indexOf('-desktop') >= 0) appName += '-desktop';
Setting.setConstant('appName', appName);
const profileDir = this.determineProfileDir(initArgs);
const resourceDirName = 'resources';
const resourceDir = profileDir + '/' + resourceDirName;
const tempDir = profileDir + '/tmp';
Setting.setConstant('env', initArgs.env);
Setting.setConstant('profileDir', profileDir);
Setting.setConstant('resourceDirName', resourceDirName);
Setting.setConstant('resourceDir', resourceDir);
Setting.setConstant('tempDir', tempDir);
await shim.fsDriver().remove(tempDir);
await fs.mkdirp(profileDir, 0o755);
await fs.mkdirp(resourceDir, 0o755);
await fs.mkdirp(tempDir, 0o755);
// Clean up any remaining watched files (they start with "edit-")
await shim.fsDriver().removeAllThatStartWith(profileDir, 'edit-');
const extraFlags = await this.readFlagsFromFile(profileDir + '/flags.txt');
initArgs = Object.assign(initArgs, extraFlags);
this.logger_.addTarget('file', { path: profileDir + '/log.txt' });
if (Setting.value('env') === 'dev') this.logger_.addTarget('console', { level: Logger.LEVEL_WARN });
this.logger_.setLevel(initArgs.logLevel);
reg.setLogger(this.logger_);
reg.dispatch = (o) => {};
this.dbLogger_.addTarget('file', { path: profileDir + '/log-database.txt' });
this.dbLogger_.setLevel(initArgs.logLevel);
if (Setting.value('env') === 'dev') {
this.dbLogger_.setLevel(Logger.LEVEL_INFO);
}
this.logger_.info('Profile directory: ' + profileDir);
this.database_ = new JoplinDatabase(new DatabaseDriverNode());
this.database_.setLogExcludedQueryTypes(['SELECT']);
this.database_.setLogger(this.dbLogger_);
await this.database_.open({ name: profileDir + '/database.sqlite' });
reg.setDb(this.database_);
BaseModel.db_ = this.database_;
await Setting.load();
if (Setting.value('firstStart')) {
const locale = shim.detectAndSetLocale(Setting);
reg.logger().info('First start: detected locale as ' + locale);
if (Setting.value('env') === 'dev') {
Setting.setValue('showTrayIcon', 0);
Setting.setValue('autoUpdateEnabled', 0);
Setting.setValue('sync.interval', 3600);
}
Setting.setValue('firstStart', 0);
} else {
setLocale(Setting.value('locale'));
}
if ('welcomeDisabled' in initArgs) Setting.setValue('welcome.enabled', !initArgs.welcomeDisabled);
if (!Setting.value('api.token')) {
EncryptionService.instance().randomHexString(64).then((token) => {
Setting.setValue('api.token', token);
});
}
time.setDateFormat(Setting.value('dateFormat'));
time.setTimeFormat(Setting.value('timeFormat'));
BaseItem.revisionService_ = RevisionService.instance();
BaseService.logger_ = this.logger_;
EncryptionService.instance().setLogger(this.logger_);
BaseItem.encryptionService_ = EncryptionService.instance();
DecryptionWorker.instance().setLogger(this.logger_);
DecryptionWorker.instance().setEncryptionService(EncryptionService.instance());
await EncryptionService.instance().loadMasterKeysFromSettings();
ResourceFetcher.instance().setFileApi(() => { return reg.syncTarget().fileApi() });
ResourceFetcher.instance().setLogger(this.logger_);
ResourceFetcher.instance().start();
SearchEngine.instance().setDb(reg.db());
SearchEngine.instance().setLogger(reg.logger());
SearchEngine.instance().scheduleSyncTables();
let currentFolderId = Setting.value('activeFolderId');
let currentFolder = null;
if (currentFolderId) currentFolder = await Folder.load(currentFolderId);
if (!currentFolder) currentFolder = await Folder.defaultFolder();
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
await MigrationService.instance().run();
return argv;
}
}
module.exports = { BaseApplication };

View File

@ -1,555 +0,0 @@
const { Database } = require('lib/database.js');
const { uuid } = require('lib/uuid.js');
const { time } = require('lib/time-utils.js');
const Mutex = require('async-mutex').Mutex;
class BaseModel {
static modelType() {
throw new Error('Must be overriden');
}
static tableName() {
throw new Error('Must be overriden');
}
static addModelMd(model) {
if (!model) return model;
if (Array.isArray(model)) {
let output = [];
for (let i = 0; i < model.length; i++) {
output.push(this.addModelMd(model[i]));
}
return output;
} else {
model = Object.assign({}, model);
model.type_ = this.modelType();
return model;
}
}
static logger() {
return this.db().logger();
}
static useUuid() {
return false;
}
static byId(items, id) {
for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return items[i];
}
return null;
}
static modelIndexById(items, id) {
for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return i;
}
return -1;
}
static modelsByIds(items, ids) {
const output = [];
for (let i = 0; i < items.length; i++) {
if (ids.indexOf(items[i].id) >= 0) {
output.push(items[i]);
}
}
return output;
}
// Prefer the use of this function to compare IDs as it handles the case where
// one ID is null and the other is "", in which case they are actually considered to be the same.
static idsEqual(id1, id2) {
if (!id1 && !id2) return true;
if (!id1 && !!id2) return false;
if (!!id1 && !id2) return false;
return id1 === id2;
}
static modelTypeToName(type) {
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
const e = BaseModel.typeEnum_[i];
if (e[1] === type) return e[0].substr(5).toLowerCase();
}
throw new Error('Unknown model type: ' + type);
}
static hasField(name) {
let fields = this.fieldNames();
return fields.indexOf(name) >= 0;
}
static fieldNames(withPrefix = false) {
let output = this.db().tableFieldNames(this.tableName());
if (!withPrefix) return output;
let p = withPrefix === true ? this.tableName() : withPrefix;
let temp = [];
for (let i = 0; i < output.length; i++) {
temp.push(p + '.' + output[i]);
}
return temp;
}
static fieldType(name, defaultValue = null) {
let fields = this.fields();
for (let i = 0; i < fields.length; i++) {
if (fields[i].name == name) return fields[i].type;
}
if (defaultValue !== null) return defaultValue;
throw new Error('Unknown field: ' + name);
}
static fields() {
return this.db().tableFields(this.tableName());
}
static removeUnknownFields(model) {
const newModel = {};
for (let n in model) {
if (!model.hasOwnProperty(n)) continue;
if (!this.hasField(n) && n !== 'type_') continue;
newModel[n] = model[n];
}
return newModel;
}
static new() {
let fields = this.fields();
let output = {};
for (let i = 0; i < fields.length; i++) {
let f = fields[i];
output[f.name] = f.default;
}
return output;
}
static modOptions(options) {
if (!options) {
options = {};
} else {
options = Object.assign({}, options);
}
if (!('isNew' in options)) options.isNew = 'auto';
if (!('autoTimestamp' in options)) options.autoTimestamp = true;
return options;
}
static count(options = null) {
if (!options) options = {};
let sql = 'SELECT count(*) as total FROM `' + this.tableName() + '`';
if (options.where) sql += ' WHERE ' + options.where;
return this.db().selectOne(sql).then((r) => {
return r ? r['total'] : 0;
});
}
static load(id) {
return this.loadByField('id', id);
}
static shortId(id) {
return id.substr(0, 5);
}
static loadByPartialId(partialId) {
return this.modelSelectAll('SELECT * FROM `' + this.tableName() + '` WHERE `id` LIKE ?', [partialId + '%']);
}
static applySqlOptions(options, sql, params = null) {
if (!options) options = {};
if (options.order && options.order.length) {
let items = [];
for (let i = 0; i < options.order.length; i++) {
const o = options.order[i];
let item = o.by;
if (options.caseInsensitive === true) item += ' COLLATE NOCASE';
if (o.dir) item += ' ' + o.dir;
items.push(item);
}
sql += ' ORDER BY ' + items.join(', ');
}
if (options.limit) sql += ' LIMIT ' + options.limit;
return { sql: sql, params: params };
}
static async allIds(options = null) {
let q = this.applySqlOptions(options, 'SELECT id FROM `' + this.tableName() + '`');
const rows = await this.db().selectAll(q.sql, q.params);
return rows.map((r) => r.id);
}
static async all(options = null) {
if (!options) options = {};
if (!options.fields) options.fields = '*';
let q = this.applySqlOptions(options, 'SELECT ' + this.db().escapeFields(options.fields) + ' FROM `' + this.tableName() + '`');
return this.modelSelectAll(q.sql);
}
static async byIds(ids, options = null) {
if (!ids.length) return [];
if (!options) options = {};
if (!options.fields) options.fields = '*';
let sql = 'SELECT ' + this.db().escapeFields(options.fields) + ' FROM `' + this.tableName() + '`';
sql += ' WHERE id IN ("' + ids.join('","') + '")';
let q = this.applySqlOptions(options, sql);
return this.modelSelectAll(q.sql);
}
static async search(options = null) {
if (!options) options = {};
if (!options.fields) options.fields = '*';
let conditions = options.conditions ? options.conditions.slice(0) : [];
let params = options.conditionsParams ? options.conditionsParams.slice(0) : [];
if (options.titlePattern) {
let pattern = options.titlePattern.replace(/\*/g, '%');
conditions.push('title LIKE ?');
params.push(pattern);
}
if ('limit' in options && options.limit <= 0) return [];
let sql = 'SELECT ' + this.db().escapeFields(options.fields) + ' FROM `' + this.tableName() + '`';
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
let query = this.applySqlOptions(options, sql, params);
return this.modelSelectAll(query.sql, query.params);
}
static modelSelectOne(sql, params = null) {
if (params === null) params = [];
return this.db().selectOne(sql, params).then((model) => {
return this.filter(this.addModelMd(model));
});
}
static modelSelectAll(sql, params = null) {
if (params === null) params = [];
return this.db().selectAll(sql, params).then((models) => {
return this.filterArray(this.addModelMd(models));
});
}
static loadByField(fieldName, fieldValue, options = null) {
if (!options) options = {};
if (!('caseInsensitive' in options)) options.caseInsensitive = false;
let sql = 'SELECT * FROM `' + this.tableName() + '` WHERE `' + fieldName + '` = ?';
if (options.caseInsensitive) sql += ' COLLATE NOCASE';
return this.modelSelectOne(sql, [fieldValue]);
}
static loadByTitle(fieldValue) {
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `title` = ?', [fieldValue]);
}
static diffObjects(oldModel, newModel) {
let output = {};
const fields = this.diffObjectsFields(oldModel, newModel);
for (let i = 0; i < fields.length; i++) {
output[fields[i]] = newModel[fields[i]];
}
if ('type_' in newModel) output.type_ = newModel.type_;
return output;
}
static diffObjectsFields(oldModel, newModel) {
let output = [];
for (let n in newModel) {
if (!newModel.hasOwnProperty(n)) continue;
if (n == 'type_') continue;
if (!(n in oldModel) || newModel[n] !== oldModel[n]) {
output.push(n);
}
}
return output;
}
static modelsAreSame(oldModel, newModel) {
const diff = this.diffObjects(oldModel, newModel);
delete diff.type_;
return !Object.getOwnPropertyNames(diff).length;
}
static saveMutex(modelOrId) {
const noLockMutex = {
acquire: function() { return null; }
};
if (!modelOrId) return noLockMutex;
let modelId = typeof modelOrId === 'string' ? modelOrId : modelOrId.id;
if (!modelId) return noLockMutex;
let mutex = BaseModel.saveMutexes_[modelId];
if (mutex) return mutex;
mutex = new Mutex();
BaseModel.saveMutexes_[modelId] = mutex;
return mutex;
}
static releaseSaveMutex(modelOrId, release) {
if (!release) return;
if (!modelOrId) return release();
let modelId = typeof modelOrId === 'string' ? modelOrId : modelOrId.id;
if (!modelId) return release();
let mutex = BaseModel.saveMutexes_[modelId];
if (!mutex) return release();
delete BaseModel.saveMutexes_[modelId];
release();
}
static saveQuery(o, options) {
let temp = {}
let fieldNames = this.fieldNames();
for (let i = 0; i < fieldNames.length; i++) {
let n = fieldNames[i];
if (n in o) temp[n] = o[n];
}
// Remove fields that are not in the `fields` list, if provided.
// Note that things like update_time, user_updated_time will still
// be part of the final list of fields if autoTimestamp is on.
// id also will stay.
if (!options.isNew && options.fields) {
const filtered = {};
for (let k in temp) {
if (!temp.hasOwnProperty(k)) continue;
if (k !== 'id' && options.fields.indexOf(k) < 0) continue;
filtered[k] = temp[k];
}
temp = filtered;
}
o = temp;
let modelId = temp.id;
let query = {};
const timeNow = time.unixMs();
if (options.autoTimestamp && this.hasField('updated_time')) {
o.updated_time = timeNow;
}
// The purpose of user_updated_time is to allow the user to manually set the time of a note (in which case
// options.autoTimestamp will be `false`). However note that if the item is later changed, this timestamp
// will be set again to the current time.
//
// The technique to modify user_updated_time while keeping updated_time current (so that sync can happen) is to
// manually set updated_time when saving and to set autoTimestamp to false, for example:
// Note.save({ id: "...", updated_time: Date.now(), user_updated_time: 1436342618000 }, { autoTimestamp: false })
if (options.autoTimestamp && this.hasField('user_updated_time')) {
o.user_updated_time = timeNow;
}
if (options.isNew) {
if (this.useUuid() && !o.id) {
modelId = uuid.create();
o.id = modelId;
}
if (!o.created_time && this.hasField('created_time')) {
o.created_time = timeNow;
}
if (!o.user_created_time && this.hasField('user_created_time')) {
o.user_created_time = o.created_time ? o.created_time : timeNow;
}
if (!o.user_updated_time && this.hasField('user_updated_time')) {
o.user_updated_time = o.updated_time ? o.updated_time : timeNow;
}
query = Database.insertQuery(this.tableName(), o);
} else {
let where = { id: o.id };
let temp = Object.assign({}, o);
delete temp.id;
query = Database.updateQuery(this.tableName(), temp, where);
}
query.id = modelId;
query.modObject = o;
return query;
}
static async save(o, options = null) {
// When saving, there's a mutex per model ID. This is because the model returned from this function
// is basically its input `o` (instead of being read from the database, for performance reasons).
// This works well in general except if that model is saved simultaneously in two places. In that
// case, the output won't be up-to-date and would cause for example display issues with out-dated
// notes being displayed. This was an issue when notes were being synchronised while being decrypted
// at the same time.
const mutexRelease = await this.saveMutex(o).acquire();
options = this.modOptions(options);
options.isNew = this.isNew(o, options);
// Diff saving is an optimisation which takes a new version of the item and an old one,
// do a diff and save only this diff. IMPORTANT: When using this make sure that both
// models have been normalised using ItemClass.filter()
const isDiffSaving = options && options.oldItem && !options.isNew;
if (isDiffSaving) {
const newObject = BaseModel.diffObjects(options.oldItem, o);
newObject.type_ = o.type_;
newObject.id = o.id;
o = newObject;
}
o = this.filter(o);
let queries = [];
let saveQuery = this.saveQuery(o, options);
let modelId = saveQuery.id;
queries.push(saveQuery);
if (options.nextQueries && options.nextQueries.length) {
queries = queries.concat(options.nextQueries);
}
let output = null;
try {
await this.db().transactionExecBatch(queries);
o = Object.assign({}, o);
if (modelId) o.id = modelId;
if ('updated_time' in saveQuery.modObject) o.updated_time = saveQuery.modObject.updated_time;
if ('created_time' in saveQuery.modObject) o.created_time = saveQuery.modObject.created_time;
if ('user_updated_time' in saveQuery.modObject) o.user_updated_time = saveQuery.modObject.user_updated_time;
if ('user_created_time' in saveQuery.modObject) o.user_created_time = saveQuery.modObject.user_created_time;
o = this.addModelMd(o);
if (isDiffSaving) {
for (let n in options.oldItem) {
if (!options.oldItem.hasOwnProperty(n)) continue;
if (n in o) continue;
o[n] = options.oldItem[n];
}
}
output = this.filter(o);
} finally {
this.releaseSaveMutex(o, mutexRelease);
}
return output;
}
static isNew(object, options) {
if (options && ('isNew' in options)) {
// options.isNew can be "auto" too
if (options.isNew === true) return true;
if (options.isNew === false) return false;
}
return !object.id;
}
static filterArray(models) {
let output = [];
for (let i = 0; i < models.length; i++) {
output.push(this.filter(models[i]));
}
return output;
}
static filter(model) {
if (!model) return model;
let output = Object.assign({}, model);
for (let n in output) {
if (!output.hasOwnProperty(n)) continue;
// The SQLite database doesn't have booleans so cast everything to int
if (output[n] === true) {
output[n] = 1;
} else if (output[n] === false) {
output[n] = 0;
} else {
const t = this.fieldType(n, Database.TYPE_UNKNOWN);
if (t === Database.TYPE_INT) {
output[n] = !n ? 0 : parseInt(output[n], 10);
}
}
}
return output;
}
static delete(id, options = null) {
if (!id) throw new Error('Cannot delete object without an ID');
options = this.modOptions(options);
return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id = ?', [id]);
}
static batchDelete(ids, options = null) {
if (!ids.length) return;
options = this.modOptions(options);
const idFieldName = options.idFieldName ? options.idFieldName : 'id';
const sql = 'DELETE FROM ' + this.tableName() + ' WHERE ' + idFieldName + ' IN ("' + ids.join('","') + '")';
return this.db().exec(sql);
}
static db() {
if (!this.db_) throw new Error('Accessing database before it has been initialised');
return this.db_;
}
static isReady() {
return !!this.db_;
}
}
BaseModel.typeEnum_ = [
['TYPE_NOTE', 1],
['TYPE_FOLDER', 2],
['TYPE_SETTING', 3],
['TYPE_RESOURCE', 4],
['TYPE_TAG', 5],
['TYPE_NOTE_TAG', 6],
['TYPE_SEARCH', 7],
['TYPE_ALARM', 8],
['TYPE_MASTER_KEY', 9],
['TYPE_ITEM_CHANGE', 10],
['TYPE_NOTE_RESOURCE', 11],
['TYPE_RESOURCE_LOCAL_STATE', 12],
['TYPE_REVISION', 13],
['TYPE_MIGRATION', 14],
];
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
const e = BaseModel.typeEnum_[i];
BaseModel[e[0]] = e[1];
}
BaseModel.db_ = null;
BaseModel.dispatch = function(o) {};
BaseModel.saveMutexes_ = {};
module.exports = BaseModel;

View File

@ -1,129 +0,0 @@
const EncryptionService = require('lib/services/EncryptionService.js');
class BaseSyncTarget {
constructor(db, options = null) {
this.db_ = db;
this.synchronizer_ = null;
this.initState_ = null;
this.logger_ = null;
this.options_ = options;
}
static supportsConfigCheck() {
return false;
}
static resourceDirName() {
return '.resource';
}
option(name, defaultValue = null) {
return this.options_ && (name in this.options_) ? this.options_[name] : defaultValue;
}
logger() {
return this.logger_;
}
setLogger(v) {
this.logger_ = v;
}
db() {
return this.db_;
}
async isAuthenticated() {
return false;
}
authRouteName() {
return null;
}
static id() {
throw new Error('id() not implemented');
}
// Note: it cannot be called just "name()" because that's a reserved keyword and
// it would throw an obscure error in React Native.
static targetName() {
throw new Error('targetName() not implemented');
}
static label() {
throw new Error('label() not implemented');
}
async initSynchronizer() {
throw new Error('initSynchronizer() not implemented');
}
async initFileApi() {
throw new Error('initFileApi() not implemented');
}
async fileApi() {
if (this.fileApi_) return this.fileApi_;
this.fileApi_ = await this.initFileApi();
return this.fileApi_;
}
fileApiSync() {
return this.fileApi_;
}
// Usually each sync target should create and setup its own file API via initFileApi()
// but for testing purposes it might be convenient to provide it here so that multiple
// clients can share and sync to the same file api (see test-utils.js)
setFileApi(v) {
this.fileApi_ = v;
}
async synchronizer() {
if (this.synchronizer_) return this.synchronizer_;
if (this.initState_ == 'started') {
// Synchronizer is already being initialized, so wait here till it's done.
return new Promise((resolve, reject) => {
const iid = setInterval(() => {
if (this.initState_ == 'ready') {
clearInterval(iid);
resolve(this.synchronizer_);
}
if (this.initState_ == 'error') {
clearInterval(iid);
reject(new Error('Could not initialise synchroniser'));
}
}, 1000);
});
} else {
this.initState_ = 'started';
try {
this.synchronizer_ = await this.initSynchronizer();
this.synchronizer_.setLogger(this.logger());
this.synchronizer_.setEncryptionService(EncryptionService.instance());
this.synchronizer_.dispatch = BaseSyncTarget.dispatch;
this.initState_ = 'ready';
return this.synchronizer_;
} catch (error) {
this.initState_ = 'error';
throw error;
}
}
}
async syncStarted() {
if (!this.synchronizer_) return false;
if (!await this.isAuthenticated()) return false;
const sync = await this.synchronizer();
return sync.state() != 'idle';
}
}
BaseSyncTarget.dispatch = (action) => {};
module.exports = BaseSyncTarget;

View File

@ -1,35 +0,0 @@
class Cache {
async getItem(name) {
let output = null;
try {
const storage = await Cache.storage();
output = await storage.getItem(name);
} catch (error) {
console.info(error);
// Defaults to returning null
}
return output;
}
async setItem(name, value, ttl = null) {
try {
const storage = await Cache.storage();
const options = {};
if (ttl !== null) options.ttl = ttl;
await storage.setItem(name, value, options);
} catch (error) {
// Defaults to not saving to cache
}
}
}
Cache.storage = async function() {
if (Cache.storage_) return Cache.storage_;
Cache.storage_ = require('node-persist');
await Cache.storage_.init({ dir: require('os').tmpdir() + '/joplin-cache', ttl: 1000 * 60 });
return Cache.storage_;
}
module.exports = Cache;

View File

@ -1,217 +0,0 @@
const { netUtils } = require('lib/net-utils');
const urlParser = require("url");
const Setting = require('lib/models/Setting');
const { Logger } = require('lib/logger.js');
const randomClipperPort = require('lib/randomClipperPort');
const enableServerDestroy = require('server-destroy');
const Api = require('lib/services/rest/Api');
const ApiResponse = require('lib/services/rest/ApiResponse');
const multiparty = require('multiparty');
class ClipperServer {
constructor() {
this.logger_ = new Logger();
this.startState_ = 'idle';
this.server_ = null;
this.port_ = null;
this.api_ = new Api(() => {
return Setting.value('api.token');
});
}
static instance() {
if (this.instance_) return this.instance_;
this.instance_ = new ClipperServer();
return this.instance_;
}
setLogger(l) {
this.logger_ = l;
this.api_.setLogger(l);
}
logger() {
return this.logger_;
}
setDispatch(d) {
this.dispatch_ = d;
}
dispatch(action) {
if (!this.dispatch_) throw new Error('dispatch not set!');
this.dispatch_(action);
}
setStartState(v) {
if (this.startState_ === v) return;
this.startState_ = v;
this.dispatch({
type: 'CLIPPER_SERVER_SET',
startState: v,
});
}
setPort(v) {
if (this.port_ === v) return;
this.port_ = v;
this.dispatch({
type: 'CLIPPER_SERVER_SET',
port: v,
});
}
async findAvailablePort() {
const tcpPortUsed = require('tcp-port-used');
let state = null;
for (let i = 0; i < 10000; i++) {
state = randomClipperPort(state, Setting.value('env'));
const inUse = await tcpPortUsed.check(state.port);
if (!inUse) return state.port;
}
throw new Error('All potential ports are in use or not available.')
}
async start() {
this.setPort(null);
this.setStartState('starting');
try {
const p = await this.findAvailablePort();
this.setPort(p);
} catch (error) {
this.setStartState('idle');
this.logger().error(error);
return;
}
this.server_ = require('http').createServer();
this.server_.on('request', async (request, response) => {
const writeCorsHeaders = (code, contentType = "application/json", additionalHeaders = null) => {
const headers = Object.assign({}, {
"Content-Type": contentType,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, PUT, PATCH, DELETE',
'Access-Control-Allow-Headers': 'X-Requested-With,content-type',
}, additionalHeaders ? additionalHeaders : {});
response.writeHead(code, headers);
}
const writeResponseJson = (code, object) => {
writeCorsHeaders(code);
response.write(JSON.stringify(object));
response.end();
}
const writeResponseText = (code, text) => {
writeCorsHeaders(code, 'text/plain');
response.write(text);
response.end();
}
const writeResponseInstance = (code, instance) => {
if (instance.type === 'attachment') {
const filename = instance.attachmentFilename ? instance.attachmentFilename : 'file';
writeCorsHeaders(code, instance.contentType ? instance.contentType : 'application/octet-stream', {
'Content-disposition': 'attachment; filename=' + filename,
'Content-Length': instance.body.length,
});
response.end(instance.body);
} else {
throw new Error('Not implemented');
}
}
const writeResponse = (code, response) => {
if (response instanceof ApiResponse) {
writeResponseInstance(code, response);
} else if (typeof response === 'string') {
writeResponseText(code, response);
} else {
writeResponseJson(code, response);
}
}
this.logger().info('Request: ' + request.method + ' ' + request.url);
const url = urlParser.parse(request.url, true);
const execRequest = async (request, body = '', files = []) => {
try {
const response = await this.api_.route(request.method, url.pathname, url.query, body, files);
writeResponse(200, response ? response : '');
} catch (error) {
this.logger().error(error);
const httpCode = error.httpCode ? error.httpCode : 500;
const msg = [];
if (httpCode >= 500) msg.push('Internal Server Error');
if (error.message) msg.push(error.message);
writeResponse(httpCode, { error: msg.join(': ') });
}
}
const contentType = request.headers['content-type'] ? request.headers['content-type'] : '';
if (request.method === 'OPTIONS') {
writeCorsHeaders(200);
response.end();
} else {
if (contentType.indexOf('multipart/form-data') === 0) {
const form = new multiparty.Form();
form.parse(request, function(error, fields, files) {
if (error) {
writeResponse(error.httpCode ? error.httpCode : 500, error.message);
return;
} else {
execRequest(
request,
fields && fields.props && fields.props.length ? fields.props[0] : '',
files && files.data ? files.data : []
);
}
});
} else {
if (request.method === 'POST' || request.method === 'PUT') {
let body = '';
request.on('data', (data) => {
body += data;
});
request.on('end', async () => {
execRequest(request, body);
});
} else {
execRequest(request);
}
}
}
});
enableServerDestroy(this.server_);
this.logger().info('Starting Clipper server on port ' + this.port_);
this.server_.listen(this.port_, '127.0.0.1');
this.setStartState('started');
}
async stop() {
this.server_.destroy();
this.server_ = null;
this.setStartState('idle');
this.setPort(null);
}
}
module.exports = ClipperServer;

View File

@ -1,216 +0,0 @@
const { Logger } = require('lib/logger.js');
const { shim } = require('lib/shim.js');
const JoplinError = require('lib/JoplinError');
const URL = require('url-parse');
const { time } = require('lib/time-utils');
const EventDispatcher = require('lib/EventDispatcher');
class DropboxApi {
constructor(options) {
this.logger_ = new Logger();
this.options_ = options;
this.authToken_ = null;
this.dispatcher_ = new EventDispatcher();
}
clientId() {
return this.options_.id;
}
clientSecret() {
return this.options_.secret;
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
}
authToken() {
return this.authToken_; // Without the "Bearer " prefix
}
on(eventName, callback) {
return this.dispatcher_.on(eventName, callback);
}
setAuthToken(v) {
this.authToken_ = v;
this.dispatcher_.dispatch('authRefreshed', this.authToken());
}
loginUrl() {
return 'https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=' + this.clientId();
}
baseUrl(endPointFormat) {
if (['content', 'api'].indexOf(endPointFormat) < 0) throw new Error('Invalid end point format: ' + endPointFormat);
return 'https://' + endPointFormat + '.dropboxapi.com/2';
}
requestToCurl_(url, options) {
let output = [];
output.push('curl');
if (options.method) output.push('-X ' + options.method);
if (options.headers) {
for (let n in options.headers) {
if (!options.headers.hasOwnProperty(n)) continue;
output.push('-H ' + "'" + n + ': ' + options.headers[n] + "'");
}
}
if (options.body) output.push('--data ' + '"' + options.body + '"');
output.push(url);
return output.join(' ');
}
async execAuthToken(authCode) {
const postData = {
code: authCode,
grant_type: 'authorization_code',
client_id: this.clientId(),
client_secret: this.clientSecret(),
};
var formBody = [];
for (var property in postData) {
var encodedKey = encodeURIComponent(property);
var encodedValue = encodeURIComponent(postData[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
formBody = formBody.join("&");
const response = await shim.fetch('https://api.dropboxapi.com/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: formBody
});
const responseText = await response.text();
if (!response.ok) throw new Error(responseText);
return JSON.parse(responseText);
}
isTokenError(status, responseText) {
if (status === 401) return true;
if (responseText.indexOf('OAuth 2 access token is malformed') >= 0) return true;
// eg. Error: POST files/create_folder_v2: Error (400): Error in call to API function "files/create_folder_v2": Must provide HTTP header "Authorization" or URL parameter "authorization".
if (responseText.indexOf('Must provide HTTP header "Authorization"') >= 0) return true;
return false;
}
async exec(method, path = '', body = null, headers = null, options = null) {
if (headers === null) headers = {};
if (options === null) options = {};
if (!options.target) options.target = 'string';
const authToken = this.authToken();
if (authToken) headers['Authorization'] = 'Bearer ' + authToken;
const endPointFormat = ['files/upload', 'files/download'].indexOf(path) >= 0 ? 'content' : 'api';
if (endPointFormat === 'api') {
headers['Content-Type'] = 'application/json';
if (body && typeof body === 'object') body = JSON.stringify(body);
} else {
headers['Content-Type'] = 'application/octet-stream';
}
const fetchOptions = {};
fetchOptions.headers = headers;
fetchOptions.method = method;
if (options.path) fetchOptions.path = options.path;
if (body) fetchOptions.body = body;
const url = path.indexOf('https://') === 0 ? path : this.baseUrl(endPointFormat) + '/' + path;
let tryCount = 0;
while (true) {
try {
let response = null;
// console.info(this.requestToCurl_(url, fetchOptions));
// console.info(method + ' ' + url);
if (options.source == 'file' && (method == 'POST' || method == 'PUT')) {
response = await shim.uploadBlob(url, fetchOptions);
} else if (options.target == 'string') {
response = await shim.fetch(url, fetchOptions);
} else { // file
response = await shim.fetchBlob(url, fetchOptions);
}
const responseText = await response.text();
// console.info('Response: ' + responseText);
let responseJson_ = null;
const loadResponseJson = () => {
if (!responseText) return null;
if (responseJson_) return responseJson_;
try {
responseJson_ = JSON.parse(responseText);
} catch (error) {
return { error: responseText };
}
return responseJson_;
}
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
const newError = (message) => {
const json = loadResponseJson();
let code = '';
if (json && json.error_summary) {
code = json.error_summary;
}
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
const shortResponseText = (responseText + '').substr(0, 1024);
const error = new JoplinError(method + ' ' + path + ': ' + message + ' (' + response.status + '): ' + shortResponseText, code);
error.httpStatus = response.status;
return error;
}
if (!response.ok) {
const json = loadResponseJson();
if (this.isTokenError(response.status, responseText)) {
this.setAuthToken(null);
}
// When using fetchBlob we only get a string (not xml or json) back
if (options.target === 'file') throw newError('fetchBlob error');
throw newError('Error');
}
if (options.responseFormat === 'text') return responseText;
return loadResponseJson();
} catch (error) {
tryCount++;
if (error && typeof error.code === 'string' && error.code.indexOf('too_many_write_operations') >= 0) {
this.logger().warn('too_many_write_operations ' + tryCount);
if (tryCount >= 3) {
throw error;
}
await time.sleep(tryCount * 2);
} else {
throw error;
}
}
}
}
}
module.exports = DropboxApi;

View File

@ -1,35 +0,0 @@
class EventDispatcher {
constructor() {
this.listeners_ = [];
}
dispatch(eventName, event = null) {
if (!this.listeners_[eventName]) return;
let ls = this.listeners_[eventName];
for (let i = 0; i < ls.length; i++) {
ls[i](event);
}
}
on(eventName, callback) {
if (!this.listeners_[eventName]) this.listeners_[eventName] = [];
this.listeners_[eventName].push(callback);
}
off(eventName, callback) {
if (!this.listeners_[eventName]) return;
let ls = this.listeners_[eventName];
for (let i = 0; i < ls.length; i++) {
if (ls[i] === callback) {
ls.splice(i, 1);
return;
}
}
}
}
module.exports = EventDispatcher;

View File

@ -1,21 +0,0 @@
const TurndownService = require('joplin-turndown')
const markdownUtils = require('lib/markdownUtils');
class HtmlToMd {
parse(html, options = {}) {
const turndownPluginGfm = require('joplin-turndown-plugin-gfm').gfm
const turndown = new TurndownService({
headingStyle: 'atx',
})
turndown.use(turndownPluginGfm)
turndown.remove('script');
turndown.remove('style');
let md = turndown.turndown(html)
if (options.baseUrl) md = markdownUtils.prependBaseUrl(md, options.baseUrl);
return md;
}
}
module.exports = HtmlToMd;

View File

@ -1,10 +0,0 @@
class JoplinError extends Error {
constructor(message, code = null) {
super(message);
this.code = code;
}
}
module.exports = JoplinError;

View File

@ -1,182 +0,0 @@
const MarkdownIt = require('markdown-it');
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode;
const Resource = require('lib/models/Resource.js');
const { shim } = require('lib/shim.js');
const { _ } = require('lib/locale');
const md5 = require('md5');
const StringUtils = require('lib/string-utils.js');
const noteStyle = require('./MdToHtml/noteStyle');
const Setting = require('./models/Setting.js');
const rules = {
image: require('./MdToHtml/rules/image'),
checkbox: require('./MdToHtml/rules/checkbox'),
katex: require('./MdToHtml/rules/katex'),
link_open: require('./MdToHtml/rules/link_open'),
html_image: require('./MdToHtml/rules/html_image'),
highlight_keywords: require('./MdToHtml/rules/highlight_keywords'),
code_inline: require('./MdToHtml/rules/code_inline'),
};
const setupLinkify = require('./MdToHtml/setupLinkify');
const hljs = require('highlight.js');
const markdownItAnchor = require('markdown-it-anchor');
const markdownItToc = require('markdown-it-toc-done-right');
// The keys must match the corresponding entry in Setting.js
const plugins = {
mark: require('markdown-it-mark'),
footnote: require('markdown-it-footnote'),
sub: require('markdown-it-sub'),
sup: require('markdown-it-sup'),
deflist: require('markdown-it-deflist'),
abbr: require('markdown-it-abbr'),
emoji: require('markdown-it-emoji'),
insert: require('markdown-it-ins'),
multitable: require('markdown-it-multimd-table'),
};
class MdToHtml {
constructor(options = null) {
if (!options) options = {};
// Must include last "/"
this.resourceBaseUrl_ = ('resourceBaseUrl' in options) ? options.resourceBaseUrl : null;
this.cachedOutputs_ = {};
}
render(body, style, options = null) {
if (!options) options = {};
if (!options.postMessageSyntax) options.postMessageSyntax = 'postMessage';
if (!options.paddingBottom) options.paddingBottom = '0';
if (!options.highlightedKeywords) options.highlightedKeywords = [];
const breaks_ = Setting.value('markdown.softbreaks') ? false : true;
const cacheKey = md5(escape(body + JSON.stringify(options) + JSON.stringify(style)));
const cachedOutput = this.cachedOutputs_[cacheKey];
if (cachedOutput) return cachedOutput;
const context = {
css: {},
cssFiles: {},
assetLoaders: {},
};
const markdownIt = new MarkdownIt({
breaks: breaks_,
linkify: true,
html: true,
highlight: function(str, lang) {
try {
let hlCode = '';
if (lang && hljs.getLanguage(lang)) {
hlCode = hljs.highlight(lang, str, true).value;
} else {
hlCode = hljs.highlightAuto(str).value;
}
if (shim.isReactNative()) {
context.css['hljs'] = shim.loadCssFromJs(options.codeTheme);
} else {
context.cssFiles['hljs'] = 'highlight/styles/' + options.codeTheme;
}
return '<pre class="hljs"><code>' + hlCode + '</code></pre>';
} catch (error) {
return '<pre class="hljs"><code>' + markdownIt.utils.escapeHtml(str) + '</code></pre>';
}
}
});
const ruleOptions = Object.assign({}, options, { resourceBaseUrl: this.resourceBaseUrl_ });
// To add a plugin, there are three options:
//
// 1. If the plugin does not need any application specific data, use the standard way:
//
// const someMarkdownPlugin = require('someMarkdownPlugin');
// markdownIt.use(someMarkdownPlugin);
//
// 2. If the plugin does not need any application specific data, and you want the user
// to be able to toggle the plugin:
//
// Add the plugin to the plugins object
// const plugins = {
// plugin: require('someMarkdownPlugin'),
// }
//
// And add a corresponding entry into Setting.js
// 'markdown.plugin.mark': {value: true, type: Setting.TYPE_BOOL, section: 'plugins', public: true, appTypes: ['mobile', 'desktop'], label: () => _('Enable ==mark== syntax')},
//
// 3. If the plugin needs application data (in ruleOptions) or needs to pass data (CSS, files to load, etc.) back
// to the application (using the context object), use the application-specific way:
//
// const imagePlugin = require('./MdToHtml/rules/image');
// markdownIt.use(imagePlugin(context, ruleOptions));
//
// Using the `context` object, a plugin can send back either CSS strings (in .css) or CSS files that need
// to be loaded (in .cssFiles). In general, the desktop app will load the CSS files and the mobile app
// will load the CSS strings.
markdownIt.use(rules.image(context, ruleOptions));
markdownIt.use(rules.checkbox(context, ruleOptions));
markdownIt.use(rules.link_open(context, ruleOptions));
markdownIt.use(rules.html_image(context, ruleOptions));
if (Setting.value('markdown.plugin.katex'))
markdownIt.use(rules.katex(context, ruleOptions));
markdownIt.use(rules.highlight_keywords(context, ruleOptions));
markdownIt.use(rules.code_inline(context, ruleOptions));
markdownIt.use(markdownItAnchor)
if (Setting.value('markdown.plugin.toc'))
markdownIt.use(markdownItToc, { listType: 'ul' })
for (let key in plugins) {
if (Setting.value('markdown.plugin.' + key))
markdownIt.use(plugins[key]);
}
setupLinkify(markdownIt);
const renderedBody = markdownIt.render(body);
const cssStrings = noteStyle(style, options);
for (let k in context.css) {
if (!context.css.hasOwnProperty(k)) continue;
cssStrings.push(context.css[k]);
}
for (let k in context.assetLoaders) {
if (!context.assetLoaders.hasOwnProperty(k)) continue;
context.assetLoaders[k]().catch(error => {
console.warn('MdToHtml: Error loading assets for ' + k + ': ', error.message);
});
}
if (options.userCss) cssStrings.push(options.userCss);
const styleHtml = '<style>' + cssStrings.join('\n') + '</style>';
const html = styleHtml + '<div id="rendered-md">' + renderedBody + '</div>';
const output = {
html: html,
cssFiles: Object.keys(context.cssFiles).map(k => context.cssFiles[k]),
};
// Fow now, we keep only the last entry in the cache
this.cachedOutputs_ = {};
this.cachedOutputs_[cacheKey] = output;
return output;
}
injectedJavaScript() {
return '';
}
}
module.exports = MdToHtml;

View File

@ -1,180 +0,0 @@
module.exports = function(style, options) {
// https://necolas.github.io/normalize.css/
const normalizeCss = `
html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}
pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}
b,strong{font-weight:bolder}small{font-size:80%}img{border-style:none}
`;
const fontFamily = "'Avenir', 'Arial', sans-serif";
const listMarginLeft = '1.7em';
const css = `
body {
font-size: ` + style.htmlFontSize + `;
color: ` + style.htmlColor + `;
line-height: ` + style.htmlLineHeight + `;
background-color: ` + style.htmlBackgroundColor + `;
font-family: ` + fontFamily + `;
padding-bottom: ` + options.paddingBottom + `;
}
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-corner {
background: none;
}
::-webkit-scrollbar-track {
border: none;
}
::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
/* Remove top padding and margin from first child so that top of rendered text is aligned to top of text editor text */
#rendered-md h1:first-child,
#rendered-md h2:first-child,
#rendered-md h3:first-child,
#rendered-md h4:first-child,
#rendered-md ul:first-child,
#rendered-md ol:first-child,
#rendered-md table:first-child,
#rendered-md blockquote:first-child,
#rendered-md img:first-child,
#rendered-md p:first-child {
margin-top: 0;
padding-top: 0;
}
p, h1, h2, h3, h4, h5, h6, ul, table {
margin-top: .6em;
margin-bottom: .65em;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.5em;
}
h1 {
font-size: 1.5em;
font-weight: bold;
border-bottom: 1px solid ` + style.htmlDividerColor + `;
padding-bottom: .3em;
}
h2 {
font-size: 1.3em;
font-weight: bold;
padding-bottom: .1em; */
}
h3 {
font-size: 1.1em;
}
h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
a {
color: ` + style.htmlLinkColor + `;
}
ul, ol {
padding-left: 0;
margin-left: 1.7em;
}
li {
margin-bottom: .4em;
}
li p {
margin-top: 0.2em;
margin-bottom: 0;
}
.resource-icon {
display: inline-block;
position: relative;
top: .5em;
text-decoration: none;
width: 1.15em;
height: 1.45em;
margin-right: 0.4em;
background-color: ` + style.htmlColor + `;
/* Awesome Font file */
-webkit-mask: url("data:image/svg+xml;utf8,<svg viewBox='0 0 1536 1892' xmlns='http://www.w3.org/2000/svg'><path d='M288 128C129 128 0 257 0 416v960c0 159 129 288 288 288h960c159 0 288-129 288-288V416c0-159-129-288-288-288H288zm449.168 236.572l263.434.565 263.431.562.584 73.412.584 73.412-42.732 1.504c-23.708.835-47.002 2.774-52.322 4.36-14.497 4.318-23.722 12.902-29.563 27.51l-5.12 12.802-1.403 291.717c-1.425 295.661-1.626 302.586-9.936 343.043-15.2 74-69.604 150.014-142.197 198.685-58.287 39.08-121.487 60.47-208.155 70.45-22.999 2.648-122.228 2.636-141.976-.024l-.002.006c-69.785-9.377-108.469-20.202-154.848-43.332-85.682-42.73-151.778-116.991-177.537-199.469-10.247-32.81-11.407-40.853-11.375-78.754.026-31.257.76-39.15 5.024-54.043 8.94-31.228 20.912-51.733 43.56-74.62 27.312-27.6 55.812-40.022 95.524-41.633 37.997-1.542 63.274 5.024 87.23 22.66 15.263 11.235 30.828 33.238 39.537 55.884 5.52 14.355 5.949 18.31 7.549 69.569 1.675 53.648 3.05 63.99 11.674 87.785 11.777 32.499 31.771 55.017 61.46 69.22 26.835 12.838 47.272 16.785 80.56 15.56 21.646-.798 30.212-2.135 43.208-6.741 38.682-13.708 70.96-44.553 86.471-82.635 16.027-39.348 15.995-38.647 15.947-361.595-.042-283.26-.09-286.272-4.568-296.153-10.958-24.171-22.488-28.492-81.074-30.377l-42.969-1.38v-147.95z'/></svg>");
}
blockquote {
border-left: 4px solid ` + style.htmlCodeBorderColor + `;
padding-left: 1.2em;
margin-left: 0;
opacity: .7;
}
table {
text-align: left-align;
border-collapse: collapse;
border: 1px solid ` + style.htmlCodeBorderColor + `;
background-color: ` + style.htmlBackgroundColor + `;
}
td, th {
padding: .5em 1em .5em 1em;
font-size: ` + style.htmlFontSize + `;
color: ` + style.htmlColor + `;
font-family: ` + fontFamily + `;
}
td {
border: 1px solid ` + style.htmlCodeBorderColor + `;
}
th {
border: 1px solid ` + style.htmlCodeBorderColor + `;
border-bottom: 2px solid ` + style.htmlCodeBorderColor + `;
background-color: ` + style.htmlTableBackgroundColor + `;
}
tr:nth-child(even) {
background-color: ` + style.htmlTableBackgroundColor + `;
}
tr:hover {
background-color: ` + style.raisedBackgroundColor + `;
}
hr {
border: none;
border-bottom: 2px solid ` + style.htmlDividerColor + `;
}
img {
max-width: 100%;
height: auto;
}
.inline-code {
border: 1px solid ` + style.htmlCodeBorderColor + `;
background-color: ` + style.htmlCodeBackgroundColor + `;
padding-right: .2em;
padding-left: .2em;
border-radius: .25em;
color: ` + style.htmlCodeColor + `;
font-size: ` + style.htmlCodeFontSize + `;
}
.highlighted-keyword {
background-color: #F3B717;
color: black;
}
@media print {
body {
height: auto !important;
}
pre {
white-space: pre-wrap;
}
.code, .inline-code {
border: 1px solid #CBCBCB;
}
}
`;
return [normalizeCss, css];
}

View File

@ -1,125 +0,0 @@
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode;
const Resource = require('lib/models/Resource.js');
const utils = require('../utils');
let checkboxIndex_ = -1;
const checkboxStyle = `
/* Remove the indentation from the checkboxes at the root of the document
(otherwise they are too far right), but keep it for their children to allow
nested lists. Make sure this value matches the UL margin. */
#rendered-md > ul > li.md-checkbox {
margin-left: -1.7em;
}
li.md-checkbox {
list-style-type: none;
}
li.md-checkbox input[type=checkbox] {
margin-right: 1em;
}
`;
function createPrefixTokens(Token, id, checked, label, postMessageSyntax, sourceToken) {
let token = null;
const tokens = [];
// A bit hard to handle errors here and it's unlikely that the token won't have a valid
// map parameter, but if it does set it to a very high value, which will be more easy to notice
// in calling code.
const lineIndex = sourceToken.map && sourceToken.map.length ? sourceToken.map[0] : 99999999;
const checkedString = checked ? 'checked' : 'unchecked';
const js = postMessageSyntax + "('checkboxclick:" + checkedString + ':' + lineIndex + "'); return true;";
token = new Token('checkbox_input', 'input', 0);
token.attrs = [
['type', 'checkbox'],
['id', id],
['onclick', js],
];
if (checked) token.attrs.push(['checked', 'true']);
tokens.push(token);
token = new Token('label_open', 'label', 1);
token.attrs = [['for', id]];
tokens.push(token);
if (label) {
token = new Token('text', '', 0);
token.content = label;
tokens.push(token);
}
return tokens;
}
function createSuffixTokens(Token) {
return [new Token('label_close', 'label', -1)];
}
function installRule(markdownIt, mdOptions, ruleOptions, context) {
markdownIt.core.ruler.push('checkbox', state => {
const tokens = state.tokens;
const Token = state.Token;
const checkboxPattern = /^\[([x|X| ])\] (.*)$/
let currentListItem = null;
let processedFirstInline = false;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'list_item_open') {
currentListItem = token;
processedFirstInline = false;
continue;
}
if (token.type === 'list_item_close') {
currentListItem = null;
processedFirstInline = false;
continue;
}
if (currentListItem && !processedFirstInline && token.type === 'inline') {
processedFirstInline = true;
const firstChild = token.children && token.children.length ? token.children[0] : null;
if (!firstChild) continue;
const matches = checkboxPattern.exec(firstChild.content);
if (!matches || matches.length < 2) continue;
checkboxIndex_++;
const checked = matches[1] !== ' ';
const id = 'md-checkbox-' + checkboxIndex_;
const label = matches.length >= 3 ? matches[2] : '';
// Prepend the text content with the checkbox markup and the opening <label> tag
// then append the </label> tag at the end of the text content.
const prefix = createPrefixTokens(Token, id, checked, label, ruleOptions.postMessageSyntax, token);
const suffix = createSuffixTokens(Token);
token.children = markdownIt.utils.arrayReplaceAt(token.children, 0, prefix);
token.children = token.children.concat(suffix);
// Add a class to the <li> container so that it can be targetted with CSS.
let itemClass = currentListItem.attrGet('class');
if (!itemClass) itemClass = '';
itemClass += ' md-checkbox';
currentListItem.attrSet('class', itemClass.trim());
context.css['checkbox'] = checkboxStyle;
}
}
});
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions, context);
};
};

View File

@ -1,20 +0,0 @@
function installRule(markdownIt, mdOptions, ruleOptions) {
const defaultRender = markdownIt.renderer.rules.code_inline || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
markdownIt.renderer.rules.code_inline = (tokens, idx, options, env, self) => {
const token = tokens[idx];
let tokenClass = token.attrGet('class');
if (!tokenClass) tokenClass = '';
tokenClass += ' inline-code';
token.attrSet('class', tokenClass.trim());
return defaultRender(tokens, idx, options, env, self);
};
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions);
};
};

View File

@ -1,71 +0,0 @@
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode;
const Resource = require('lib/models/Resource.js');
const utils = require('../utils');
const StringUtils = require('lib/string-utils.js');
const md5 = require('md5');
function createHighlightedTokens(Token, splitted) {
let token;
const output = [];
for (let i = 0; i < splitted.length; i++) {
const text = splitted[i];
if (!text) continue;
if (i % 2 === 0) {
token = new Token('text', '', 0);
token.content = text;
output.push(token);
} else {
token = new Token('highlighted_keyword_open', 'span', 1);
token.attrs = [['class', 'highlighted-keyword']];
output.push(token);
token = new Token('text', '', 0);
token.content = text;
output.push(token);
token = new Token('highlighted_keyword_close', 'span', -1);
output.push(token);
}
}
return output;
}
function installRule(markdownIt, mdOptions, ruleOptions) {
const divider = md5(Date.now().toString() + Math.random().toString());
markdownIt.core.ruler.push('highlight_keywords', state => {
const keywords = ruleOptions.highlightedKeywords;
if (!keywords || !keywords.length) return;
const tokens = state.tokens;
const Token = state.Token;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type !== 'inline') continue;
for (let j = 0; j < token.children.length; j++) {
const child = token.children[j];
if (child.type !== 'text') continue;
const splitted = StringUtils.surroundKeywords(keywords, child.content, divider, divider).split(divider);
const splittedTokens = createHighlightedTokens(Token, splitted);
if (splittedTokens.length <= 1) continue;
token.children = markdownIt.utils.arrayReplaceAt(token.children, j, splittedTokens);
j += splittedTokens.length - 1;
}
}
});
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions);
};
};

View File

@ -1,56 +0,0 @@
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode;
const Resource = require('lib/models/Resource.js');
const utils = require('../utils');
function renderImageHtml(before, src, after, ruleOptions) {
const resourceId = Resource.urlToId(src);
const resource = ruleOptions.resources[resourceId];
if (!resource) return '<div>' + utils.loaderImage() + '</div>';
const mime = resource.mime ? resource.mime.toLowerCase() : '';
if (Resource.isSupportedImageMimeType(mime)) {
let newSrc = './' + Resource.filename(resource);
if (ruleOptions.resourceBaseUrl) newSrc = ruleOptions.resourceBaseUrl + newSrc;
return '<img ' + before + ' data-resource-id="' + resource.id + '" src="' + newSrc + '" ' + after + '/>';
}
return '[Image: ' + htmlentities(resource.title) + ' (' + htmlentities(mime) + ')]';
}
function installRule(markdownIt, mdOptions, ruleOptions) {
const htmlBlockDefaultRender = markdownIt.renderer.rules.html_block || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
const htmlInlineDefaultRender = markdownIt.renderer.rules.html_inline || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
const imageRegex = /<img(.*?)src=["'](.*?)["'](.*?)\/>/
const handleImageTags = function(defaultRender) {
return function(tokens, idx, options, env, self) {
const token = tokens[idx];
const content = token.content;
if (!content.match(imageRegex)) return defaultRender(tokens, idx, options, env, self);
return content.replace(imageRegex, (v, before, src, after) => {
if (!Resource.isResourceUrl(src)) return defaultRender(tokens, idx, options, env, self);
return renderImageHtml(before, src, after, ruleOptions);
});
}
}
// It seems images sometimes are inline, sometimes a block
// to make sure they both render correctly.
markdownIt.renderer.rules.html_block = handleImageTags(htmlBlockDefaultRender);
markdownIt.renderer.rules.html_inline = handleImageTags(htmlInlineDefaultRender);
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions);
};
};

View File

@ -1,36 +0,0 @@
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode;
const Resource = require('lib/models/Resource.js');
const utils = require('../utils');
function installRule(markdownIt, mdOptions, ruleOptions) {
const defaultRender = markdownIt.renderer.rules.image;
markdownIt.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const src = utils.getAttr(token.attrs, 'src');
const title = utils.getAttr(token.attrs, 'title');
if (!Resource.isResourceUrl(src)) return defaultRender(tokens, idx, options, env, self);
const resourceId = Resource.urlToId(src);
const resource = ruleOptions.resources[resourceId];
if (!resource) return '<div>' + utils.loaderImage() + '</div>';
const mime = resource.mime ? resource.mime.toLowerCase() : '';
if (Resource.isSupportedImageMimeType(mime)) {
let realSrc = './' + Resource.filename(resource);
if (ruleOptions.resourceBaseUrl) realSrc = ruleOptions.resourceBaseUrl + realSrc;
let output = '<img data-from-md data-resource-id="' + resource.id + '" title="' + htmlentities(title) + '" src="' + realSrc + '"/>';
return output;
}
return defaultRender(tokens, idx, options, env, self);
};
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions);
};
};

View File

@ -1,259 +0,0 @@
// Based on https://github.com/waylonflinn/markdown-it-katex
'use strict';
const { shim } = require('lib/shim');
const Setting = require('lib/models/Setting');
var katex = require('katex');
const katexCss = require('lib/csstojs/katex.css.js');
const md5 = require('md5');
// const style = `
// /*
// This is to fix https://github.com/laurent22/joplin/issues/764
// Without this, the tag attached to an equation float at an absolute position of the page,
// instead of a position relative to the container.
// 2018-03-13: No longer needed??
// */
// /*
// .katex-display>.katex>.katex-html {
// position: relative;
// }
// */
// `
// Test if potential opening or closing delimieter
// Assumes that there is a "$" at state.src[pos]
function isValidDelim(state, pos) {
var prevChar, nextChar,
max = state.posMax,
can_open = true,
can_close = true;
prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1;
nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1;
// Check non-whitespace conditions for opening and closing, and
// check that closing delimeter isn't followed by a number
if (prevChar === 0x20/* " " */ || prevChar === 0x09/* \t */ ||
(nextChar >= 0x30/* "0" */ && nextChar <= 0x39/* "9" */)) {
can_close = false;
}
if (nextChar === 0x20/* " " */ || nextChar === 0x09/* \t */) {
can_open = false;
}
return {
can_open: can_open,
can_close: can_close
};
}
function math_inline(state, silent) {
var start, match, token, res, pos, esc_count;
if (state.src[state.pos] !== "$") { return false; }
res = isValidDelim(state, state.pos);
if (!res.can_open) {
if (!silent) { state.pending += "$"; }
state.pos += 1;
return true;
}
// First check for and bypass all properly escaped delimieters
// This loop will assume that the first leading backtick can not
// be the first character in state.src, which is known since
// we have found an opening delimieter already.
start = state.pos + 1;
match = start;
while ( (match = state.src.indexOf("$", match)) !== -1) {
// Found potential $, look for escapes, pos will point to
// first non escape when complete
pos = match - 1;
while (state.src[pos] === "\\") { pos -= 1; }
// Even number of escapes, potential closing delimiter found
if ( ((match - pos) % 2) == 1 ) { break; }
match += 1;
}
// No closing delimter found. Consume $ and continue.
if (match === -1) {
if (!silent) { state.pending += "$"; }
state.pos = start;
return true;
}
// Check if we have empty content, ie: $$. Do not parse.
if (match - start === 0) {
if (!silent) { state.pending += "$$"; }
state.pos = start + 1;
return true;
}
// Check for valid closing delimiter
res = isValidDelim(state, match);
if (!res.can_close) {
if (!silent) { state.pending += "$"; }
state.pos = start;
return true;
}
if (!silent) {
token = state.push('math_inline', 'math', 0);
token.markup = "$";
token.content = state.src.slice(start, match);
}
state.pos = match + 1;
return true;
}
function math_block(state, start, end, silent){
var firstLine, lastLine, next, lastPos, found = false, token,
pos = state.bMarks[start] + state.tShift[start],
max = state.eMarks[start]
if(pos + 2 > max){ return false; }
if(state.src.slice(pos,pos+2)!=='$$'){ return false; }
pos += 2;
firstLine = state.src.slice(pos,max);
if(silent){ return true; }
if(firstLine.trim().slice(-2)==='$$'){
// Single line expression
firstLine = firstLine.trim().slice(0, -2);
found = true;
}
for(next = start; !found; ){
next++;
if(next >= end){ break; }
pos = state.bMarks[next]+state.tShift[next];
max = state.eMarks[next];
if(pos < max && state.tShift[next] < state.blkIndent){
// non-empty line with negative indent should stop the list:
break;
}
if(state.src.slice(pos,max).trim().slice(-2)==='$$'){
lastPos = state.src.slice(0,max).lastIndexOf('$$');
lastLine = state.src.slice(pos,lastPos);
found = true;
}
}
state.line = next + 1;
token = state.push('math_block', 'math', 0);
token.block = true;
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '')
+ state.getLines(start + 1, next, state.tShift[start], true)
+ (lastLine && lastLine.trim() ? lastLine : '');
token.map = [ start, state.line ];
token.markup = '$$';
return true;
}
let assetsLoaded_ = false;
let cache_ = {};
module.exports = function(context, ruleOptions) {
// Keep macros that persist across Katex blocks to allow defining a macro
// in one block and re-using it later in other blocks.
// https://github.com/laurent22/joplin/issues/1105
context.__katex = { macros: {} };
const addContextAssets = () => {
context.css['katex'] = katexCss;
context.assetLoaders['katex'] = async () => {
if (assetsLoaded_) return;
// In node, the fonts are simply copied using copycss to where Katex expects to find them, which is under app/gui/note-viewer/fonts
// In React Native, it's more complicated and we need to download and copy them to the right directory. Ideally, we should embed
// them as an asset and copy them from there (or load them from there by modifying Katex CSS), but for now that will do.
if (shim.isReactNative()) {
// Fonts must go under the resourceDir directory because this is the baseUrl of NoteBodyViewer
const baseDir = Setting.value('resourceDir');
await shim.fsDriver().mkdir(baseDir + '/fonts');
await shim.fetchBlob('https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0-beta1/fonts/KaTeX_Main-Regular.woff2', { overwrite: false, path: baseDir + '/fonts/KaTeX_Main-Regular.woff2' });
await shim.fetchBlob('https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0-beta1/fonts/KaTeX_Math-Italic.woff2', { overwrite: false, path: baseDir + '/fonts/KaTeX_Math-Italic.woff2' });
await shim.fetchBlob('https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0-beta1/fonts/KaTeX_Size1-Regular.woff2', { overwrite: false, path: baseDir + '/fonts/KaTeX_Size1-Regular.woff2' });
}
assetsLoaded_ = true;
};
}
function renderToStringWithCache(latex, options) {
const cacheKey = md5(escape(latex) + escape(JSON.stringify(options)));
if (cacheKey in cache_) {
return cache_[cacheKey];
} else {
const beforeMacros = JSON.stringify(options.macros);
const output = katex.renderToString(latex, options);
const afterMacros = JSON.stringify(options.macros);
// Don't cache the formulas that add macros, otherwise
// they won't be added on second run.
if (beforeMacros === afterMacros) cache_[cacheKey] = output;
return output;
}
}
return function(md, options) {
// Default options
options = options || {};
options.macros = context.__katex.macros;
// set KaTeX as the renderer for markdown-it-simplemath
var katexInline = function(latex){
options.displayMode = false;
try{
return renderToStringWithCache(latex, options);
} catch(error){
if(options.throwOnError){ console.log(error); }
return latex;
}
};
var inlineRenderer = function(tokens, idx){
addContextAssets();
return katexInline(tokens[idx].content);
};
var katexBlock = function(latex){
options.displayMode = true;
try{
return "<p>" + renderToStringWithCache(latex, options) + "</p>";
} catch(error){
if(options.throwOnError){ console.log(error); }
return latex;
}
}
var blockRenderer = function(tokens, idx){
addContextAssets();
return katexBlock(tokens[idx].content) + '\n';
}
md.inline.ruler.after('escape', 'math_inline', math_inline);
md.block.ruler.after('blockquote', 'math_block', math_block, {
alt: [ 'paragraph', 'reference', 'blockquote', 'list' ]
});
md.renderer.rules.math_inline = inlineRenderer;
md.renderer.rules.math_block = blockRenderer;
};
};

View File

@ -1,41 +0,0 @@
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = (new Entities()).encode;
const Resource = require('lib/models/Resource.js');
const utils = require('../utils');
const loaderImage = '<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="16px" height="16px" viewBox="0 0 128 128" xml:space="preserve"><g><circle cx="16" cy="64" r="16" fill="#000000" fill-opacity="1"/><circle cx="16" cy="64" r="16" fill="#555555" fill-opacity="0.67" transform="rotate(45,64,64)"/><circle cx="16" cy="64" r="16" fill="#949494" fill-opacity="0.42" transform="rotate(90,64,64)"/><circle cx="16" cy="64" r="16" fill="#cccccc" fill-opacity="0.2" transform="rotate(135,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(180,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(225,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(270,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(315,64,64)"/><animateTransform attributeName="transform" type="rotate" values="0 64 64;315 64 64;270 64 64;225 64 64;180 64 64;135 64 64;90 64 64;45 64 64" calcMode="discrete" dur="720ms" repeatCount="indefinite"></animateTransform></g></svg>';
function installRule(markdownIt, mdOptions, ruleOptions) {
markdownIt.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx];
let href = utils.getAttr(token.attrs, 'href');
const text = utils.getAttr(token.attrs, 'text');
const isResourceUrl = Resource.isResourceUrl(href);
const title = isResourceUrl ? utils.getAttr(token.attrs, 'title') : href;
let resourceIdAttr = "";
let icon = "";
let hrefAttr = '#';
if (isResourceUrl) {
const resourceId = Resource.pathToId(href);
href = "joplin://" + resourceId;
resourceIdAttr = "data-resource-id='" + resourceId + "'";
icon = '<span class="resource-icon"></span>';
} else {
// If the link is a plain URL (as opposed to a resource link), set the href to the actual
// link. This allows the link to be exported too when exporting to PDF.
hrefAttr = href;
}
let js = ruleOptions.postMessageSyntax + "(" + JSON.stringify(href) + "); return false;";
if (hrefAttr.indexOf('#') === 0 && href.indexOf('#') === 0) js = ''; // If it's an internal anchor, don't add any JS since the webview is going to handle navigating to the right place
// if (js) hrefAttr = '#';
return "<a data-from-md " + resourceIdAttr + " title='" + htmlentities(title) + "' href='" + hrefAttr + "' onclick='" + js + "'>" + icon;
};
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions);
};
};

View File

@ -1,29 +0,0 @@
module.exports = function(markdownIt) {
// Add `file:` protocol in linkify to allow text in the format of "file://..." to translate into
// file-URL links in html view
markdownIt.linkify.add('file:', {
validate: function (text, pos, self) {
var tail = text.slice(pos);
if (!self.re.file) {
// matches all local file URI on Win/Unix/MacOS systems including reserved characters in some OS (i.e. no OS specific sanity check)
self.re.file = new RegExp('^[\\/]{2,3}[\\S]+');
}
if (self.re.file.test(tail)) {
return tail.match(self.re.file)[0].length;
}
return 0;
}
});
// enable file link URLs in MarkdownIt. Keeps other URL restrictions of MarkdownIt untouched.
// Format [link name](file://...)
markdownIt.validateLink = function (url) {
var BAD_PROTO_RE = /^(vbscript|javascript|data):/;
var GOOD_DATA_RE = /^data:image\/(gif|png|jpeg|webp);/;
// url should be normalized at this point, and existing entities are decoded
var str = url.trim().toLowerCase();
return BAD_PROTO_RE.test(str) ? (GOOD_DATA_RE.test(str) ? true : false) : true;
}
}

View File

@ -1,14 +0,0 @@
module.exports = {
getAttr: function(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 defaultValue;
},
loaderImage: function() {
return '<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="16px" height="16px" viewBox="0 0 128 128" xml:space="preserve"><g><circle cx="16" cy="64" r="16" fill="#000000" fill-opacity="1"/><circle cx="16" cy="64" r="16" fill="#555555" fill-opacity="0.67" transform="rotate(45,64,64)"/><circle cx="16" cy="64" r="16" fill="#949494" fill-opacity="0.42" transform="rotate(90,64,64)"/><circle cx="16" cy="64" r="16" fill="#cccccc" fill-opacity="0.2" transform="rotate(135,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(180,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(225,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(270,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(315,64,64)"/><animateTransform attributeName="transform" type="rotate" values="0 64 64;315 64 64;270 64 64;225 64 64;180 64 64;135 64 64;90 64 64;45 64 64" calcMode="discrete" dur="720ms" repeatCount="indefinite"></animateTransform></g></svg>';
},
};

View File

@ -1,65 +0,0 @@
const webviewLib = {};
webviewLib.handleInternalLink = function(event, anchorNode) {
const href = anchorNode.getAttribute('href');
if (href.indexOf('#') === 0) {
event.preventDefault();
let old_hash = location.hash;
location.hash = href;
// HACK
// For some reason anchors at the bottom cause the webview to move itself
// so that the content is aligned with the top of the screen
// This basically refreshes the scroll view so that is returns to a normal
// position, the scroll positions stays correct though
// Additionally an anchor could not be clicked twice because the location
// would not change, this fixes that also
setTimeout(function() { location.hash = old_hash; }, 10);
return true;
}
return false;
}
webviewLib.getParentAnchorElement = function(element) {
let counter = 0;
while (true) {
if (counter++ >= 10000) {
console.warn('been looping for too long - exiting')
return null;
}
if (!element) return null;
if (element.nodeName === 'A') return element;
element = element.parentElement;
}
}
webviewLib.initialize = function(options) {
webviewLib.options_ = options;
}
document.addEventListener('click', function(event) {
const anchor = webviewLib.getParentAnchorElement(event.target);
if (!anchor) return;
// Prevent URLs added via <a> tags from being opened within the application itself
// otherwise it would open the whole website within the WebView.
// Note that we already handle some links in html_inline.js, however not all of them
// go through this plugin, in particular links coming from third-party packages such
// as Katex.
if (!anchor.hasAttribute('data-from-md')) {
if (webviewLib.handleInternalLink(event, anchor)) return;
event.preventDefault();
webviewLib.options_.postMessage(anchor.getAttribute('href'));
return;
}
// If this is an internal link, jump to the anchor directly
if (anchor.hasAttribute('data-from-md')) {
if (webviewLib.handleInternalLink(event, anchor)) return;
}
});

View File

@ -1,39 +0,0 @@
class ModelCache {
constructor(maxSize) {
this.cache_ = [];
this.maxSize_ = maxSize;
}
fromCache(ModelClass, id) {
for (let i = 0; i < this.cache_.length; i++) {
const c = this.cache_[i];
if (c.id === id && c.modelType === ModelClass.modelType()) return c
}
return null;
}
cache(ModelClass, id, model) {
if (this.fromCache(ModelClass, model.id)) return;
this.cache_.push({
id: id,
model: model,
modelType: ModelClass.modelType(),
});
if (this.cache_.length > this.maxSize_) {
this.cache_.splice(0, 1);
}
}
async load(ModelClass, id) {
const cached = this.fromCache(ModelClass, id);
if (cached) return cached.model;
const output = await ModelClass.load(id);
this.cache(ModelClass, id, output);
return output;
}
}
module.exports = ModelCache;

View File

@ -1,61 +0,0 @@
const ObjectUtils = {};
ObjectUtils.sortByValue = function(object) {
const temp = [];
for (let k in object) {
if (!object.hasOwnProperty(k)) continue;
temp.push({
key: k,
value: object[k],
});
}
temp.sort(function(a, b) {
let v1 = a.value;
let v2 = b.value;
if (typeof v1 === 'string') v1 = v1.toLowerCase();
if (typeof v2 === 'string') v2 = v2.toLowerCase();
if (v1 === v2) return 0;
return v1 < v2 ? -1 : +1;
});
const output = {};
for (let i = 0; i < temp.length; i++) {
const item = temp[i];
output[item.key] = item.value;
}
return output;
}
ObjectUtils.fieldsEqual = function(o1, o2) {
if ((!o1 || !o2) && (o1 !== o2)) return false;
for (let k in o1) {
if (!o1.hasOwnProperty(k)) continue;
if (o1[k] !== o2[k]) return false;
}
const c1 = Object.getOwnPropertyNames(o1);
const c2 = Object.getOwnPropertyNames(o2);
if (c1.length !== c2.length) return false;
return true;
}
ObjectUtils.convertValuesToFunctions = function(o) {
const output = {};
for (let n in o) {
if (!o.hasOwnProperty(n)) continue;
output[n] = () => { return typeof o[n] === 'function' ? o[n]() : o[n]; }
}
return output;
}
ObjectUtils.isEmpty = function(o) {
if (!o) return true;
return Object.keys(o).length === 0 && o.constructor === Object;
}
module.exports = ObjectUtils;

View File

@ -1,73 +0,0 @@
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { _ } = require('lib/locale.js');
const DropboxApi = require('lib/DropboxApi');
const Setting = require('lib/models/Setting.js');
const { parameters } = require('lib/parameters.js');
const { FileApi } = require('lib/file-api.js');
const { Synchronizer } = require('lib/synchronizer.js');
const { FileApiDriverDropbox } = require('lib/file-api-driver-dropbox.js');
class SyncTargetDropbox extends BaseSyncTarget {
static id() {
return 7;
}
constructor(db, options = null) {
super(db, options);
this.api_ = null;
}
static targetName() {
return 'dropbox';
}
static label() {
return _('Dropbox');
}
authRouteName() {
return 'DropboxLogin';
}
async isAuthenticated() {
const f = await this.fileApi();
return !!f.driver().api().authToken();
}
async api() {
const fileApi = await this.fileApi();
return fileApi.driver().api();
}
async initFileApi() {
const params = parameters().dropbox;
const api = new DropboxApi({
id: params.id,
secret: params.secret,
});
api.on('authRefreshed', (auth) => {
this.logger().info('Saving updated Dropbox auth.');
Setting.setValue('sync.' + SyncTargetDropbox.id() + '.auth', auth ? auth : null);
});
const authToken = Setting.value('sync.' + SyncTargetDropbox.id() + '.auth');
api.setAuthToken(authToken);
const appDir = '';
const fileApi = new FileApi(appDir, new FileApiDriverDropbox(api));
fileApi.setSyncTargetId(SyncTargetDropbox.id());
fileApi.setLogger(this.logger());
return fileApi;
}
async initSynchronizer() {
if (!(await this.isAuthenticated())) throw new Error('User is not authentified');
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetDropbox;

View File

@ -1,42 +0,0 @@
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { _ } = require('lib/locale.js');
const Setting = require('lib/models/Setting.js');
const { FileApi } = require('lib/file-api.js');
const { FileApiDriverLocal } = require('lib/file-api-driver-local.js');
const { Synchronizer } = require('lib/synchronizer.js');
class SyncTargetFilesystem extends BaseSyncTarget {
static id() {
return 2;
}
static targetName() {
return 'filesystem';
}
static label() {
return _('File system');
}
async isAuthenticated() {
return true;
}
async initFileApi() {
const syncPath = Setting.value('sync.2.path');
const driver = new FileApiDriverLocal();
const fileApi = new FileApi(syncPath, driver);
fileApi.setLogger(this.logger());
fileApi.setSyncTargetId(SyncTargetFilesystem.id());
await driver.mkdir(syncPath);
return fileApi;
}
async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetFilesystem;

View File

@ -1,39 +0,0 @@
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { _ } = require('lib/locale.js');
const Setting = require('lib/models/Setting.js');
const { FileApi } = require('lib/file-api.js');
const { FileApiDriverMemory } = require('lib/file-api-driver-memory.js');
const { Synchronizer } = require('lib/synchronizer.js');
class SyncTargetMemory extends BaseSyncTarget {
static id() {
return 1;
}
static targetName() {
return 'memory';
}
static label() {
return 'Memory';
}
async isAuthenticated() {
return true;
}
initFileApi() {
const fileApi = new FileApi('/root', new FileApiDriverMemory());
fileApi.setLogger(this.logger());
fileApi.setSyncTargetId(SyncTargetMemory.id());
return fileApi;
}
async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetMemory;

View File

@ -1,57 +0,0 @@
// The Nextcloud sync target is essentially a wrapper over the WebDAV sync target,
// thus all the calls to SyncTargetWebDAV to avoid duplicate code.
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { _ } = require('lib/locale.js');
const Setting = require('lib/models/Setting.js');
const { FileApi } = require('lib/file-api.js');
const { Synchronizer } = require('lib/synchronizer.js');
const WebDavApi = require('lib/WebDavApi');
const SyncTargetWebDAV = require('lib/SyncTargetWebDAV');
const { FileApiDriverWebDav } = require('lib/file-api-driver-webdav');
class SyncTargetNextcloud extends BaseSyncTarget {
static id() {
return 5;
}
static supportsConfigCheck() {
return true;
}
static targetName() {
return 'nextcloud';
}
static label() {
return _('Nextcloud');
}
async isAuthenticated() {
return true;
}
static async checkConfig(options) {
return SyncTargetWebDAV.checkConfig(options);
}
async initFileApi() {
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetNextcloud.id(), {
path: () => Setting.value('sync.5.path'),
username: () => Setting.value('sync.5.username'),
password: () => Setting.value('sync.5.password'),
});
fileApi.setLogger(this.logger());
return fileApi;
}
async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetNextcloud;

View File

@ -1,89 +0,0 @@
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { _ } = require('lib/locale.js');
const { OneDriveApi } = require('lib/onedrive-api.js');
const Setting = require('lib/models/Setting.js');
const { parameters } = require('lib/parameters.js');
const { FileApi } = require('lib/file-api.js');
const { Synchronizer } = require('lib/synchronizer.js');
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
class SyncTargetOneDrive extends BaseSyncTarget {
static id() {
return 3;
}
constructor(db, options = null) {
super(db, options);
this.api_ = null;
}
static targetName() {
return 'onedrive';
}
static label() {
return _('OneDrive');
}
async isAuthenticated() {
return this.api().auth();
}
syncTargetId() {
return SyncTargetOneDrive.id();
}
oneDriveParameters() {
return parameters().oneDrive;
}
authRouteName() {
return 'OneDriveLogin';
}
api() {
if (this.api_) return this.api_;
const isPublic = Setting.value('appType') != 'cli';
this.api_ = new OneDriveApi(this.oneDriveParameters().id, this.oneDriveParameters().secret, isPublic);
this.api_.setLogger(this.logger());
this.api_.on('authRefreshed', (a) => {
this.logger().info('Saving updated OneDrive auth.');
Setting.setValue('sync.' + this.syncTargetId() + '.auth', a ? JSON.stringify(a) : null);
});
let auth = Setting.value('sync.' + this.syncTargetId() + '.auth');
if (auth) {
try {
auth = JSON.parse(auth);
} catch (error) {
this.logger().warn('Could not parse OneDrive auth token');
this.logger().warn(error);
auth = null;
}
this.api_.setAuth(auth);
}
return this.api_;
}
async initFileApi() {
const appDir = await this.api().appDirectory();
const fileApi = new FileApi(appDir, new FileApiDriverOneDrive(this.api()));
fileApi.setSyncTargetId(this.syncTargetId());
fileApi.setLogger(this.logger());
return fileApi;
}
async initSynchronizer() {
if (!await this.isAuthenticated()) throw new Error('User is not authentified');
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetOneDrive;

View File

@ -1,37 +0,0 @@
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
const { _ } = require('lib/locale.js');
const { OneDriveApi } = require('lib/onedrive-api.js');
const Setting = require('lib/models/Setting.js');
const { parameters } = require('lib/parameters.js');
const { FileApi } = require('lib/file-api.js');
const { Synchronizer } = require('lib/synchronizer.js');
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
class SyncTargetOneDriveDev extends SyncTargetOneDrive {
static id() {
return 4;
}
static targetName() {
return 'onedrive_dev';
}
static label() {
return _('OneDrive Dev (For testing only)');
}
syncTargetId() {
return SyncTargetOneDriveDev.id();
}
oneDriveParameters() {
return parameters('dev').oneDrive;
}
}
const staticSelf = SyncTargetOneDriveDev;
module.exports = SyncTargetOneDriveDev;

View File

@ -1,52 +0,0 @@
class SyncTargetRegistry {
static classById(syncTargetId) {
const info = SyncTargetRegistry.reg_[syncTargetId];
if (!info) throw new Error('Invalid id: ' + syncTargetId);
return info.classRef;
}
static addClass(SyncTargetClass) {
this.reg_[SyncTargetClass.id()] = {
id: SyncTargetClass.id(),
name: SyncTargetClass.targetName(),
label: SyncTargetClass.label(),
classRef: SyncTargetClass,
supportsConfigCheck: SyncTargetClass.supportsConfigCheck(),
};
}
static nameToId(name) {
for (let n in this.reg_) {
if (!this.reg_.hasOwnProperty(n)) continue;
if (this.reg_[n].name === name) return this.reg_[n].id;
}
throw new Error('Name not found: ' + name);
}
static idToMetadata(id) {
for (let n in this.reg_) {
if (!this.reg_.hasOwnProperty(n)) continue;
if (this.reg_[n].id === id) return this.reg_[n];
}
throw new Error('ID not found: ' + id);
}
static idToName(id) {
return this.idToMetadata(id).name;
}
static idAndLabelPlainObject() {
let output = {};
for (let n in this.reg_) {
if (!this.reg_.hasOwnProperty(n)) continue;
output[n] = this.reg_[n].label;
}
return output;
}
}
SyncTargetRegistry.reg_ = {};
module.exports = SyncTargetRegistry;

View File

@ -1,84 +0,0 @@
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
const { _ } = require('lib/locale.js');
const Setting = require('lib/models/Setting.js');
const { FileApi } = require('lib/file-api.js');
const { Synchronizer } = require('lib/synchronizer.js');
const WebDavApi = require('lib/WebDavApi');
const { FileApiDriverWebDav } = require('lib/file-api-driver-webdav');
class SyncTargetWebDAV extends BaseSyncTarget {
static id() {
return 6;
}
static supportsConfigCheck() {
return true;
}
static targetName() {
return 'webdav';
}
static label() {
return _('WebDAV');
}
async isAuthenticated() {
return true;
}
static async newFileApi_(syncTargetId, options) {
const apiOptions = {
baseUrl: () => options.path(),
username: () => options.username(),
password: () => options.password(),
};
const api = new WebDavApi(apiOptions);
const driver = new FileApiDriverWebDav(api);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(syncTargetId);
return fileApi;
}
static async checkConfig(options) {
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetWebDAV.id(), options);
fileApi.requestRepeatCount_ = 0;
const output = {
ok: false,
errorMessage: '',
};
try {
const result = await fileApi.stat('');
if (!result) throw new Error('WebDAV directory not found: ' + options.path());
output.ok = true;
} catch (error) {
output.errorMessage = error.message;
if (error.code) output.errorMessage += ' (Code ' + error.code + ')';
}
return output;
}
async initFileApi() {
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetWebDAV.id(), {
path: () => Setting.value('sync.6.path'),
username: () => Setting.value('sync.6.username'),
password: () => Setting.value('sync.6.password'),
});
fileApi.setLogger(this.logger());
return fileApi;
}
async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}
module.exports = SyncTargetWebDAV;

View File

@ -1,400 +0,0 @@
const { Logger } = require('lib/logger.js');
const { shim } = require('lib/shim.js');
const parseXmlString = require('xml2js').parseString;
const JoplinError = require('lib/JoplinError');
const URL = require('url-parse');
const { rtrimSlashes, ltrimSlashes } = require('lib/path-utils.js');
const base64 = require('base-64');
// Note that the d: namespace (the DAV namespace) is specific to Nextcloud. The RFC for example uses "D:" however
// we make all the tags and attributes lowercase so we handle both the Nextcloud style and RFC. Hopefully other
// implementations use the same namespaces. If not, extra processing can be done in `nameProcessor`, for
// example to convert a custom namespace to "d:" so that it can be used by the rest of the code.
// In general, we should only deal with things in "d:", which is the standard DAV namespace.
class WebDavApi {
constructor(options) {
this.logger_ = new Logger();
this.options_ = options;
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
}
authToken() {
if (!this.options_.username() || !this.options_.password()) return null;
try {
// Note: Non-ASCII passwords will throw an error about Latin1 characters - https://github.com/laurent22/joplin/issues/246
// Tried various things like the below, but it didn't work on React Native:
//return base64.encode(utf8.encode(this.options_.username() + ':' + this.options_.password()));
return base64.encode(this.options_.username() + ':' + this.options_.password());
} catch (error) {
error.message = 'Cannot encode username/password: ' + error.message;
throw error;
}
}
baseUrl() {
return rtrimSlashes(this.options_.baseUrl());
}
relativeBaseUrl() {
const url = new URL(this.baseUrl());
return url.pathname + url.query;
}
async xmlToJson(xml) {
let davNamespaces = []; // Yes, there can be more than one... xmlns:a="DAV:" xmlns:D="DAV:"
const nameProcessor = (name) => {
if (name.indexOf('xmlns:') !== 0) {
// Check if the current name is within the DAV namespace. If it is, normalise it
// by moving it to the "d:" namespace, which is what all the functions are using.
const p = name.split(':');
if (p.length == 2) {
const ns = p[0];
if (davNamespaces.indexOf(ns) >= 0) {
name = 'd:' + p[1];
}
}
}
return name.toLowerCase();
};
const attrValueProcessor = (value, name) => {
if (value.toLowerCase() === 'dav:') {
const p = name.split(':');
davNamespaces.push(p[p.length - 1]);
}
}
const options = {
tagNameProcessors: [nameProcessor],
attrNameProcessors: [nameProcessor],
attrValueProcessors: [attrValueProcessor]
}
return new Promise((resolve, reject) => {
parseXmlString(xml, options, (error, result) => {
if (error) {
resolve(null); // Error handled by caller which will display the XML text (or plain text) if null is returned from this function
return;
}
resolve(result);
});
});
}
valueFromJson(json, keys, type) {
let output = json;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
// console.info(key, typeof key, typeof output, typeof output === 'object' && (key in output), Array.isArray(output));
if (typeof key === 'number' && !Array.isArray(output)) return null;
if (typeof key === 'string' && (typeof output !== 'object' || !(key in output))) return null;
output = output[key];
}
if (type === 'string') {
// If the XML has not attribute the value is directly a string
// If the XML node has attributes, the value is under "_".
// Eg for this XML, the string will be under {"_":"Thu, 01 Feb 2018 17:24:05 GMT"}:
// <a:getlastmodified b:dt="dateTime.rfc1123">Thu, 01 Feb 2018 17:24:05 GMT</a:getlastmodified>
// For this XML, the value will be "Thu, 01 Feb 2018 17:24:05 GMT"
// <a:getlastmodified>Thu, 01 Feb 2018 17:24:05 GMT</a:getlastmodified>
if (typeof output === 'object' && '_' in output) output = output['_'];
if (typeof output !== 'string') return null;
return output;
}
if (type === 'object') {
if (!Array.isArray(output) && typeof output === 'object') return output;
return null;
}
if (type === 'array') {
return Array.isArray(output) ? output : null;
}
return null;
}
stringFromJson(json, keys) {
return this.valueFromJson(json, keys, 'string');
}
objectFromJson(json, keys) {
return this.valueFromJson(json, keys, 'object');
}
arrayFromJson(json, keys) {
return this.valueFromJson(json, keys, 'array');
}
resourcePropByName(resource, outputType, propName) {
const propStats = resource['d:propstat'];
let output = null;
if (!Array.isArray(propStats)) throw new Error('Missing d:propstat property');
for (let i = 0; i < propStats.length; i++) {
const props = propStats[i]['d:prop'];
if (!Array.isArray(props) || !props.length) continue;
const prop = props[0];
if (Array.isArray(prop[propName])) {
output = prop[propName];
break;
}
}
if (outputType === 'string') {
// If the XML has not attribute the value is directly a string
// If the XML node has attributes, the value is under "_".
// Eg for this XML, the string will be under {"_":"Thu, 01 Feb 2018 17:24:05 GMT"}:
// <a:getlastmodified b:dt="dateTime.rfc1123">Thu, 01 Feb 2018 17:24:05 GMT</a:getlastmodified>
// For this XML, the value will be "Thu, 01 Feb 2018 17:24:05 GMT"
// <a:getlastmodified>Thu, 01 Feb 2018 17:24:05 GMT</a:getlastmodified>
output = output[0];
if (typeof output === 'object' && '_' in output) output = output['_'];
if (typeof output !== 'string') return null;
return output;
}
if (outputType === 'array') {
return output;
}
throw new Error('Invalid output type: ' + outputType);
}
async execPropFind(path, depth, fields = null, options = null) {
if (fields === null) fields = ['d:getlastmodified'];
let fieldsXml = '';
for (let i = 0; i < fields.length; i++) {
fieldsXml += '<' + fields[i] + '/>';
}
// To find all available properties:
//
// const body=`<?xml version="1.0" encoding="utf-8" ?>
// <propfind xmlns="DAV:">
// <propname/>
// </propfind>`;
const body = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop xmlns:oc="http://owncloud.org/ns">
` + fieldsXml + `
</d:prop>
</d:propfind>`;
return this.exec('PROPFIND', path, body, { 'Depth': depth }, options);
}
requestToCurl_(url, options) {
let output = [];
output.push('curl');
output.push('-v');
if (options.method) output.push('-X ' + options.method);
if (options.headers) {
for (let n in options.headers) {
if (!options.headers.hasOwnProperty(n)) continue;
output.push('-H ' + '"' + n + ': ' + options.headers[n] + '"');
}
}
if (options.body) output.push('--data ' + "'" + options.body + "'");
output.push(url);
return output.join(' ');
}
handleNginxHack_(jsonResponse, newErrorHandler) {
// Trying to fix 404 error issue with Nginx WebDAV server.
// https://github.com/laurent22/joplin/issues/624
// https://github.com/laurent22/joplin/issues/808
// Not tested but someone confirmed it worked - https://github.com/laurent22/joplin/issues/808#issuecomment-443552858
// and fix is narrowly scoped so shouldn't affect anything outside this particular edge case.
//
// The issue is that instead of an HTTP 404 status code, Nginx returns 200 but with this response:
//
// <?xml version="1.0" encoding="utf-8" ?>
// <D:multistatus xmlns:D="DAV:">
// <D:response>
// <D:href>/notes/ecd4027a5271483984b00317433e2c66.md</D:href>
// <D:propstat>
// <D:prop/>
// <D:status>HTTP/1.1 404 Not Found</D:status>
// </D:propstat>
// </D:response>
// </D:multistatus>
//
// So we need to parse this and find that it is in fact a 404 error.
//
// HOWEVER, some implementations also return 404 for missing props, for example SeaFile:
// (indicates that the props "getlastmodified" is not present, but this call is only
// used when checking the conf, so we don't really need it)
// https://github.com/laurent22/joplin/issues/1137
//
// <?xml version='1.0' encoding='UTF-8'?>
// <ns0:multistatus xmlns:ns0="DAV:">
// <ns0:response>
// <ns0:href>/seafdav/joplin/</ns0:href>
// <ns0:propstat>
// <ns0:prop>
// <ns0:getlastmodified/>
// </ns0:prop>
// <ns0:status>HTTP/1.1 404 Not Found</ns0:status>
// </ns0:propstat>
// <ns0:propstat>
// <ns0:prop>
// <ns0:resourcetype>
// <ns0:collection/>
// </ns0:resourcetype>
// </ns0:prop>
// <ns0:status>HTTP/1.1 200 OK</ns0:status>
// </ns0:propstat>
// </ns0:response>
// </ns0:multistatus>
//
// As a simple fix for now it's enough to check if ALL the statuses are 404 - in that case
// it really means that the file doesn't exist. Otherwise we can proceed as usual.
const responseArray = this.arrayFromJson(jsonResponse, ['d:multistatus', 'd:response']);
if (responseArray && responseArray.length === 1) {
const propStats = this.arrayFromJson(jsonResponse, ['d:multistatus', 'd:response', 0, 'd:propstat']);
if (!propStats.length) return;
let count404 = 0;
for (let i = 0; i < propStats.length; i++) {
const status = this.arrayFromJson(jsonResponse, ['d:multistatus', 'd:response', 0, 'd:propstat', i, 'd:status']);
if (status && status.length && status[0].indexOf('404') >= 0) count404++;
}
if (count404 === propStats.length) throw newErrorHandler('Not found', 404);
}
}
// curl -u admin:123456 'http://nextcloud.local/remote.php/dav/files/admin/' -X PROPFIND --data '<?xml version="1.0" encoding="UTF-8"?>
// <d:propfind xmlns:d="DAV:">
// <d:prop xmlns:oc="http://owncloud.org/ns">
// <d:getlastmodified/>
// </d:prop>
// </d:propfind>'
async exec(method, path = '', body = null, headers = null, options = null) {
if (headers === null) headers = {};
if (options === null) options = {};
if (!options.responseFormat) options.responseFormat = 'json';
if (!options.target) options.target = 'string';
const authToken = this.authToken();
if (authToken) headers['Authorization'] = 'Basic ' + authToken;
// On iOS, the network lib appends a If-None-Match header to PROPFIND calls, which is kind of correct because
// the call is idempotent and thus could be cached. According to RFC-7232 though only GET and HEAD should have
// this header for caching purposes. It makes no mention of PROPFIND.
// So possibly because of this, Seafile (and maybe other WebDAV implementations) responds with a "412 Precondition Failed"
// error when this header is present for PROPFIND call on existing resources. This is also kind of correct because there is a resource
// with this eTag and since this is neither a GET nor HEAD call, it is supposed to respond with 412 if the resource is present.
// The "solution", an ugly one, is to send a purposely invalid string as eTag, which will bypass the If-None-Match check - Seafile
// finds out that no resource has this ID and simply sends the requested data.
// Also add a random value to make sure the eTag is unique for each call.
if (['GET', 'HEAD'].indexOf(method) < 0) headers['If-None-Match'] = 'JoplinIgnore-' + Math.floor(Math.random() * 100000);
const fetchOptions = {};
fetchOptions.headers = headers;
fetchOptions.method = method;
if (options.path) fetchOptions.path = options.path;
if (body) fetchOptions.body = body;
const url = this.baseUrl() + '/' + path;
let response = null;
// console.info('WebDAV Call', method + ' ' + url, headers, options);
// console.info(this.requestToCurl_(url, fetchOptions));
if (options.source == 'file' && (method == 'POST' || method == 'PUT')) {
if (fetchOptions.path) {
const fileStat = await shim.fsDriver().stat(fetchOptions.path);
if (fileStat) fetchOptions.headers['Content-Length'] = fileStat.size + '';
}
response = await shim.uploadBlob(url, fetchOptions);
} else if (options.target == 'string') {
if (typeof body === 'string') fetchOptions.headers['Content-Length'] = shim.stringByteLength(body) + '';
response = await shim.fetch(url, fetchOptions);
} else { // file
response = await shim.fetchBlob(url, fetchOptions);
}
const responseText = await response.text();
// console.info('WebDAV Response', responseText);
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
const newError = (message, code = 0) => {
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
const shortResponseText = (responseText + '').substr(0, 1024);
return new JoplinError(method + ' ' + path + ': ' + message + ' (' + code + '): ' + shortResponseText, code);
}
let responseJson_ = null;
const loadResponseJson = async () => {
if (!responseText) return null;
if (responseJson_) return responseJson_;
responseJson_ = await this.xmlToJson(responseText);
if (!responseJson_) throw newError('Cannot parse XML response', response.status);
return responseJson_;
}
if (!response.ok) {
// When using fetchBlob we only get a string (not xml or json) back
if (options.target === 'file') throw newError('fetchBlob error', response.status);
let json = null;
try {
json = await loadResponseJson();
} catch (error) {
// Just send back the plain text in newErro()
}
if (json && json['d:error']) {
const code = json['d:error']['s:exception'] ? json['d:error']['s:exception'].join(' ') : response.status;
const message = json['d:error']['s:message'] ? json['d:error']['s:message'].join("\n") : 'Unknown error 1';
throw newError(message + ' (Exception ' + code + ')', response.status);
}
throw newError('Unknown error 2', response.status);
}
if (options.responseFormat === 'text') return responseText;
// The following methods may have a response depending on the server but it's not
// standard (some return a plain string, other XML, etc.) and we don't check the
// response anyway since we rely on the HTTP status code so return null.
if (['MKCOL', 'DELETE', 'PUT', 'MOVE'].indexOf(method) >= 0) return null;
const output = await loadResponseJson();
this.handleNginxHack_(output, newError);
// Check that we didn't get for example an HTML page (as an error) instead of the JSON response
// null responses are possible, for example for DELETE calls
if (output !== null && typeof output === 'object' && !('d:multistatus' in output)) throw newError('Not a valid WebDAV response');
return output;
}
}
module.exports = WebDavApi;

View File

@ -1,83 +0,0 @@
const welcomeAssets = require('./welcomeAssets');
const Note = require('lib/models/Note');
const Setting = require('lib/models/Setting');
const Folder = require('lib/models/Folder');
const Tag = require('lib/models/Tag');
const Resource = require('lib/models/Resource');
const { shim } = require('lib/shim');
const { uuid } = require('lib/uuid');
const { fileExtension, basename} = require('lib/path-utils');
const { pregQuote } = require('lib/string-utils');
class WelcomeUtils {
static async createWelcomeItems() {
const output = {
defaultFolderId: null,
};
const folderAssets = welcomeAssets.folders;
const tempDir = Setting.value('resourceDir');
for (let i = 0; i < folderAssets.length; i++) {
const folderAsset = folderAssets[i];
const folder = await Folder.save({ title: folderAsset.title + ' (' + Setting.appTypeToLabel(Setting.value('appType')) + ')'});
if (!output.defaultFolderId) output.defaultFolderId = folder.id;
}
const noteAssets = welcomeAssets.notes;
for (let i = noteAssets.length - 1; i >= 0; i--) {
const noteAsset = noteAssets[i];
let noteBody = noteAsset.body;
for (let resourceUrl in noteAsset.resources) {
if (!noteAsset.resources.hasOwnProperty(resourceUrl)) continue;
const resourceAsset = noteAsset.resources[resourceUrl];
const ext = fileExtension(resourceUrl);
const tempFilePath = tempDir + '/' + uuid.create() + '.tmp.' + ext;
await shim.fsDriver().writeFile(tempFilePath, resourceAsset.body, 'base64');
const resource = await shim.createResourceFromPath(tempFilePath, {
title: basename(resourceUrl),
});
await shim.fsDriver().remove(tempFilePath);
const regex = new RegExp(pregQuote('(' + resourceUrl + ')'), 'g');
noteBody = noteBody.replace(regex, '(:/' + resource.id + ')');
}
const note = await Note.save({
parent_id: output.defaultFolderId,
title: noteAsset.title,
body: noteBody,
});
if (noteAsset.tags) await Tag.setNoteTagsByTitles(note.id, noteAsset.tags);
}
return output;
}
static async install(dispatch) {
if (!Setting.value('welcome.enabled')) {
Setting.setValue('welcome.wasBuilt', true);
return;
}
if (!Setting.value('welcome.wasBuilt')) {
const result = await WelcomeUtils.createWelcomeItems();
Setting.setValue('welcome.wasBuilt', true);
dispatch({
type: 'FOLDER_SELECT',
id: result.defaultFolderId,
});
Setting.setValue('activeFolderId', result.defaultFolderId);
}
}
}
module.exports = WelcomeUtils;

View File

@ -1,83 +0,0 @@
const React = require('react'); const Component = React.Component;
const { View, Button, StyleSheet, TouchableOpacity } = require('react-native');
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
import { RNCamera } from 'react-native-camera';
const Icon = require('react-native-vector-icons/Ionicons').default;
const { _ } = require('lib/locale.js');
class CameraView extends Component {
constructor() {
super();
this.state = {
snapping: false,
};
this.back_onPress = this.back_onPress.bind(this);
this.photo_onPress = this.photo_onPress.bind(this);
}
back_onPress() {
if (this.props.onCancel) this.props.onCancel();
}
async photo_onPress() {
if (!this.camera || !this.props.onPhoto) return;
this.setState({ snapping: true });
const result = await this.camera.takePictureAsync({
quality: 0.8,
exif: true,
fixOrientation: true
});
if (this.props.onPhoto) this.props.onPhoto(result);
this.setState({ snapping: false });
}
render() {
const theme = themeStyle(this.props.theme);
const photoIcon = this.state.snapping ? 'md-checkmark' : 'md-camera';
return (
<View style={this.props.style}>
<RNCamera
style={{flex:1}}
ref={ref => { this.camera = ref; }}
type={RNCamera.Constants.Type.back}
permissionDialogTitle={_('Permission to use camera')}
permissionDialogMessage={_('Your permission to use your camera is required.')}
>
<View style={{flex:1, justifyContent:'space-between', flexDirection:'column'}}>
<View style={{flex:1, justifyContent:'flex-start'}}>
<TouchableOpacity onPress={this.back_onPress}>
<View style={{ marginLeft:5, marginTop:5, borderRadius:90, width:50,height:50, display:'flex', backgroundColor:'#ffffff55', justifyContent:'center', alignItems:'center'}}>
<Icon name={'md-arrow-back'} style={{
fontSize: 40,
color: 'black',
}} />
</View>
</TouchableOpacity>
</View>
<View style={{flex:1, justifyContent:'center', alignItems:'flex-end', flexDirection:'row'}}>
<TouchableOpacity onPress={this.photo_onPress}>
<View style={{marginBottom:20, borderRadius:90, width:90,height:90,backgroundColor:'#ffffffaa', display:'flex', justifyContent:'center', alignItems:'center'}}>
<Icon name={photoIcon} style={{
fontSize: 60,
color: 'black',
}} />
</View>
</TouchableOpacity>
</View>
</View>
</RNCamera>
</View>
);
}
}
module.exports = CameraView;

View File

@ -1,132 +0,0 @@
const React = require('react');
const { TouchableOpacity, TouchableWithoutFeedback , Dimensions, Text, Modal, View } = require('react-native');
const { ItemList } = require('lib/components/ItemList.js');
class Dropdown extends React.Component {
constructor() {
super();
this.headerRef_ = null;
}
UNSAFE_componentWillMount() {
this.setState({
headerSize: { x: 0, y: 0, width: 0, height: 0 },
listVisible: false,
});
}
updateHeaderCoordinates() {
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
this.headerRef_.measure((fx, fy, width, height, px, py) => {
this.setState({
headerSize: { x: px, y: py, width: width, height: height }
});
});
}
render() {
const items = this.props.items;
const itemHeight = 60;
const windowHeight = Dimensions.get('window').height - 50;
// Dimensions doesn't return quite the right dimensions so leave an extra gap to make
// sure nothing is off screen.
const listMaxHeight = windowHeight;
const listHeight = Math.min(items.length * itemHeight, listMaxHeight);
const maxListTop = windowHeight - listHeight;
const listTop = Math.min(maxListTop, this.state.headerSize.y + this.state.headerSize.height);
const wrapperStyle = {
width: this.state.headerSize.width,
height: listHeight + 2, // +2 for the border (otherwise it makes the scrollbar appear)
marginTop: listTop,
marginLeft: this.state.headerSize.x,
};
const itemListStyle = Object.assign({}, this.props.itemListStyle ? this.props.itemListStyle : {}, {
borderWidth: 1,
borderColor: '#ccc',
});
const itemWrapperStyle = Object.assign({}, this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}, {
flex:1,
justifyContent: 'center',
height: itemHeight,
paddingLeft: 20,
paddingRight: 10,
});
const headerWrapperStyle = Object.assign({}, this.props.headerWrapperStyle ? this.props.headerWrapperStyle : {}, {
height: 35,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
});
const headerStyle = Object.assign({}, this.props.headerStyle ? this.props.headerStyle : {}, {
flex: 1,
});
const headerArrowStyle = Object.assign({}, this.props.headerStyle ? this.props.headerStyle : {}, {
flex: 0,
marginRight: 10,
});
const itemStyle = Object.assign({}, this.props.itemStyle ? this.props.itemStyle : {}, {
});
let headerLabel = '...';
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.value === this.props.selectedValue) {
headerLabel = item.label;
break;
}
}
if (this.props.labelTransform && this.props.labelTransform === 'trim') headerLabel = headerLabel.trim();
const closeList = () => {
this.setState({ listVisible: false });
}
const itemRenderer= (item) => {
return (
<TouchableOpacity style={itemWrapperStyle} key={item.value} onPress={() => { closeList(); if (this.props.onValueChange) this.props.onValueChange(item.value); }}>
<Text ellipsizeMode="tail" numberOfLines={1} style={itemStyle} key={item.value}>{item.label}</Text>
</TouchableOpacity>
);
}
return (
<View style={{flex: 1, flexDirection: 'column' }}>
<TouchableOpacity style={headerWrapperStyle} ref={(ref) => this.headerRef_ = ref} onPress={() => {
this.updateHeaderCoordinates();
this.setState({ listVisible: true });
}}>
<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}>{headerLabel}</Text>
<Text style={headerArrowStyle}>{'▼'}</Text>
</TouchableOpacity>
<Modal transparent={true} visible={this.state.listVisible} onRequestClose={() => { closeList(); }} >
<TouchableWithoutFeedback onPressOut={() => { closeList() }}>
<View style={{flex:1}}>
<View style={wrapperStyle}>
<ItemList
style={itemListStyle}
items={this.props.items}
itemHeight={itemHeight}
itemRenderer={(item) => { return itemRenderer(item) }}
/>
</View>
</View>
</TouchableWithoutFeedback>
</Modal>
</View>
);
}
}
module.exports = { Dropdown };

View File

@ -1,103 +0,0 @@
const React = require('react');
const { Text, TouchableHighlight, View, StyleSheet, ScrollView } = require('react-native');
class ItemList extends React.Component {
constructor() {
super();
this.scrollTop_ = 0;
}
itemCount(props = null) {
if (props === null) props = this.props;
return this.props.items ? this.props.items.length : this.props.itemComponents.length;
}
updateStateItemIndexes(props = null, height = null) {
if (props === null) props = this.props;
if (height === null) {
if (!this.state) return;
height = this.state.height;
}
const topItemIndex = Math.max(0, Math.floor(this.scrollTop_ / props.itemHeight));
const visibleItemCount = Math.ceil(height / props.itemHeight);
let bottomItemIndex = topItemIndex + visibleItemCount - 1;
if (bottomItemIndex >= this.itemCount(props)) bottomItemIndex = this.itemCount(props) - 1;
this.setState({
topItemIndex: topItemIndex,
bottomItemIndex: bottomItemIndex,
});
}
UNSAFE_componentWillMount() {
this.setState({
topItemIndex: 0,
bottomItemIndex: 0,
height: 0,
itemHeight: this.props.itemHeight ? this.props.itemHeight : 0,
});
this.updateStateItemIndexes();
}
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.itemHeight) {
this.setState({
itemHeight: newProps.itemHeight,
});
}
this.updateStateItemIndexes(newProps);
}
onScroll(event) {
this.scrollTop_ = Math.floor(event.nativeEvent.contentOffset.y);
this.updateStateItemIndexes();
}
onLayout(event) {
this.setState({ height: event.nativeEvent.layout.height });
this.updateStateItemIndexes(null, event.nativeEvent.layout.height);
}
render() {
const style = this.props.style ? this.props.style : {};
const itemHeight = this.state.itemHeight;
//if (!this.props.itemHeight) throw new Error('itemHeight is required');
let itemComps = [];
if (this.props.items) {
const items = this.props.items;
const blankItem = function(key, height) {
return <View key={key} style={{height:height}}></View>
}
itemComps = [blankItem('top', this.state.topItemIndex * this.props.itemHeight)];
for (let i = this.state.topItemIndex; i <= this.state.bottomItemIndex; i++) {
const itemComp = this.props.itemRenderer(items[i]);
itemComps.push(itemComp);
}
itemComps.push(blankItem('bottom', (items.length - this.state.bottomItemIndex - 1) * this.props.itemHeight));
} else {
itemComps = this.props.itemComponents;
}
return (
<ScrollView scrollEventThrottle={500} onLayout={(event) => { this.onLayout(event); }} style={style} onScroll={ (event) => { this.onScroll(event) }}>
{ itemComps }
</ScrollView>
);
}
}
module.exports = { ItemList };

View File

@ -1,83 +0,0 @@
const React = require('react');
const { Text, Modal, View, StyleSheet, Button } = require('react-native');
const { themeStyle } = require('lib/components/global-style.js');
const { _ } = require('lib/locale');
class ModalDialog extends React.Component {
constructor() {
super();
this.styles_ = {};
}
styles() {
const themeId = this.props.theme;
const theme = themeStyle(themeId);
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
let styles = {
modalWrapper: {
flex: 1,
justifyContent: 'center',
},
modalContentWrapper: {
flex:1,
flexDirection: 'column',
backgroundColor: theme.backgroundColor,
borderWidth: 1,
borderColor:theme.dividerColor,
margin: 20,
padding: 10,
borderRadius: 5,
},
modalContentWrapper2: {
flex:1,
},
title: Object.assign({}, theme.normalText, {
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
paddingBottom: 10,
fontWeight: 'bold',
}),
buttonRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: theme.dividerColor,
paddingTop: 10,
},
};
this.styles_[themeId] = StyleSheet.create(styles);
return this.styles_[themeId];
}
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 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 disabled={!buttonBarEnabled} title={_('OK')} onPress={this.props.onOkPress}></Button>
</View>
<View style={{flex:1, marginLeft: 5}}>
<Button disabled={!buttonBarEnabled} title={_('Cancel')} onPress={this.props.onCancelPress}></Button>
</View>
</View>
</View>
</Modal>
</View>
);
}
}
module.exports = ModalDialog;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 B

View File

@ -1,141 +0,0 @@
const React = require('react'); const Component = React.Component;
const { StyleSheet, Text } = require('react-native');
const Icon = require('react-native-vector-icons/Ionicons').default;
const ReactNativeActionButton = require('react-native-action-button').default;
const { connect } = require('react-redux');
const { globalStyle } = require('lib/components/global-style.js');
const { _ } = require('lib/locale.js');
const styles = StyleSheet.create({
actionButtonIcon: {
fontSize: 20,
height: 22,
color: 'white',
},
itemText: {
// fontSize: 14, // Cannot currently set fontsize since the bow surrounding the label has a fixed size
}
});
class ActionButtonComponent extends React.Component {
constructor() {
super();
this.state = {
buttonIndex: 0,
};
}
UNSAFE_componentWillReceiveProps(newProps) {
if ('buttonIndex' in newProps) {
this.setState({ buttonIndex: newProps.buttonIndex });
}
}
newTodo_press() {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Note',
noteId: null,
folderId: this.props.parentFolderId,
itemType: 'todo',
});
}
newNote_press() {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Note',
noteId: null,
folderId: this.props.parentFolderId,
itemType: 'note',
});
}
newFolder_press() {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Folder',
folderId: null,
});
}
render() {
let buttons = this.props.buttons ? this.props.buttons : [];
if (this.props.addFolderNoteButtons) {
if (this.props.folders.length) {
buttons.push({
title: _('New to-do'),
onPress: () => { this.newTodo_press() },
color: '#9b59b6',
icon: 'md-checkbox-outline',
});
buttons.push({
title: _('New note'),
onPress: () => { this.newNote_press() },
color: '#9b59b6',
icon: 'md-document',
});
}
buttons.push({
title: _('New notebook'),
onPress: () => { this.newFolder_press() },
color: '#3498db',
icon: 'md-folder',
});
}
let buttonComps = [];
for (let i = 0; i < buttons.length; i++) {
let button = buttons[i];
let buttonTitle = button.title ? button.title : '';
let key = buttonTitle.replace(/\s/g, '_') + '_' + button.icon;
buttonComps.push(
<ReactNativeActionButton.Item key={key} buttonColor={button.color} title={buttonTitle} onPress={button.onPress}>
<Icon name={button.icon} style={styles.actionButtonIcon} />
</ReactNativeActionButton.Item>
);
}
if (!buttonComps.length && !this.props.mainButton) {
return <ReactNativeActionButton style={{ display: 'none' }}/>
}
let mainButton = this.props.mainButton ? this.props.mainButton : {};
let mainIcon = mainButton.icon ? <Icon name={mainButton.icon} style={styles.actionButtonIcon} /> : <Icon name="md-add" style={styles.actionButtonIcon} />
if (this.props.multiStates) {
if (!this.props.buttons || !this.props.buttons.length) throw new Error('Multi-state button requires at least one state');
if (this.state.buttonIndex < 0 || this.state.buttonIndex >= this.props.buttons.length) throw new Error('Button index out of bounds: ' + this.state.buttonIndex + '/' + this.props.buttons.length);
let button = this.props.buttons[this.state.buttonIndex];
let mainIcon = <Icon name={button.icon} style={styles.actionButtonIcon} />
return (
<ReactNativeActionButton
icon={mainIcon}
buttonColor="rgba(231,76,60,1)"
onPress={() => { button.onPress() }}
/>
);
} else {
return (
<ReactNativeActionButton textStyle={styles.itemText} icon={mainIcon} buttonColor="rgba(231,76,60,1)" onPress={ function() { } }>
{ buttonComps }
</ReactNativeActionButton>
);
}
}
}
const ActionButton = connect(
(state) => {
return {
folders: state.folders,
locale: state.settings.locale,
};
}
)(ActionButtonComponent)
module.exports = { ActionButton };

View File

@ -1,92 +0,0 @@
const React = require('react'); const Component = React.Component;
const { connect } = require('react-redux');
const { NotesScreen } = require('lib/components/screens/notes.js');
const { SearchScreen } = require('lib/components/screens/search.js');
const { KeyboardAvoidingView, Keyboard, Platform, View } = require('react-native');
const { _ } = require('lib/locale.js');
const { themeStyle } = require('lib/components/global-style.js');
class AppNavComponent extends Component {
constructor() {
super();
this.previousRouteName_ = null;
this.state = {
autoCompletionBarExtraHeight: 0, // Extra padding for the auto completion bar at the top of the keyboard
}
}
UNSAFE_componentWillMount() {
if (Platform.OS === 'ios') {
this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this.keyboardDidShow.bind(this));
this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this.keyboardDidHide.bind(this));
}
}
componentWillUnmount() {
if (this.keyboardDidShowListener) this.keyboardDidShowListener.remove();
if (this.keyboardDidHideListener) this.keyboardDidHideListener.remove();
this.keyboardDidShowListener = null;
this.keyboardDidHideListener = null;
}
keyboardDidShow () {
this.setState({ autoCompletionBarExtraHeight: 30 })
}
keyboardDidHide () {
this.setState({ autoCompletionBarExtraHeight:0 })
}
render() {
if (!this.props.route) throw new Error('Route must not be null');
// Note: certain screens are kept into memory, in particular Notes and Search
// so that the scroll position is not lost when the user navigate away from them.
let route = this.props.route;
let Screen = null;
let notesScreenVisible = false;
let searchScreenVisible = false;
if (route.routeName == 'Notes') {
notesScreenVisible = true;
} else if (route.routeName == 'Search') {
searchScreenVisible = true;
} else {
Screen = this.props.screens[route.routeName].screen;
}
// Keep the search screen loaded if the user is viewing a note from that search screen
// so that if the back button is pressed, the screen is still loaded. However, unload
// it if navigating away.
let searchScreenLoaded = searchScreenVisible || (this.previousRouteName_ == 'Search' && route.routeName == 'Note');
this.previousRouteName_ = route.routeName;
const theme = themeStyle(this.props.theme);
const style = { flex: 1, backgroundColor: theme.backgroundColor }
return (
<KeyboardAvoidingView behavior={ Platform.OS === 'ios' ? "padding" : null } style={style}>
<NotesScreen visible={notesScreenVisible} navigation={{ state: route }} />
{ searchScreenLoaded && <SearchScreen visible={searchScreenVisible} navigation={{ state: route }} /> }
{ (!notesScreenVisible && !searchScreenVisible) && <Screen navigation={{ state: route }} /> }
<View style={{ height: this.state.autoCompletionBarExtraHeight }} />
</KeyboardAvoidingView>
);
}
}
const AppNav = connect(
(state) => {
return {
route: state.route,
theme: state.settings.theme,
};
}
)(AppNavComponent)
module.exports = { AppNav };

View File

@ -1,40 +0,0 @@
const React = require('react'); const Component = React.Component;
const { StyleSheet } = require('react-native');
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
const styleObject_ = {
screen: {
flex: 1,
backgroundColor: globalStyle.backgroundColor,
},
};
const styles_ = StyleSheet.create(styleObject_);
let rootStyles_ = {};
class BaseScreenComponent extends React.Component {
styles() {
return styles_;
}
styleObject() {
return styleObject_;
}
rootStyle(themeId) {
const theme = themeStyle(themeId);
if (rootStyles_[themeId]) return rootStyles_[themeId];
rootStyles_[themeId] = StyleSheet.create({
root: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
});
return rootStyles_[themeId];
}
}
module.exports = { BaseScreenComponent };

View File

@ -1,71 +0,0 @@
const React = require('react'); const Component = React.Component;
const { StyleSheet, View, TouchableHighlight } = require('react-native');
const Icon = require('react-native-vector-icons/Ionicons').default;
const styles = {
checkboxIcon: {
fontSize: 20,
height: 22,
//marginRight: 10,
},
};
class Checkbox extends Component {
constructor() {
super();
this.state = {
checked: false,
}
}
UNSAFE_componentWillMount() {
this.setState({ checked: this.props.checked });
}
UNSAFE_componentWillReceiveProps(newProps) {
if ('checked' in newProps) {
this.setState({ checked: newProps.checked });
}
}
onPress() {
let newChecked = !this.state.checked;
this.setState({ checked: newChecked });
if (this.props.onChange) this.props.onChange(newChecked);
}
render() {
const iconName = this.state.checked ? 'md-checkbox-outline' : 'md-square-outline';
let style = this.props.style ? Object.assign({}, this.props.style) : {};
style.justifyContent = 'center';
style.alignItems = 'center';
let checkboxIconStyle = Object.assign({}, styles.checkboxIcon);
if (style.color) checkboxIconStyle.color = style.color;
if (style.paddingTop) checkboxIconStyle.marginTop = style.paddingTop;
if (style.paddingBottom) checkboxIconStyle.marginBottom = style.paddingBottom;
if (style.paddingLeft) checkboxIconStyle.marginLeft = style.paddingLeft;
if (style.paddingRight) checkboxIconStyle.marginRight = style.paddingRight;
const thStyle = {
justifyContent: 'center',
alignItems: 'center',
};
if (style && style.display === 'none') return <View/>
//if (style.display) thStyle.display = style.display;
return (
<TouchableHighlight onPress={() => this.onPress()} style={thStyle}>
<Icon name={iconName} style={checkboxIconStyle}/>
</TouchableHighlight>
);
}
}
module.exports = { Checkbox };

View File

@ -1,131 +0,0 @@
const Setting = require('lib/models/Setting.js');
const { Platform } = require('react-native');
const globalStyle = {
fontSize: 16,
margin: 15, // No text and no interactive component should be within this margin
itemMarginTop: 10,
itemMarginBottom: 10,
backgroundColor: "#ffffff",
color: "#555555", // For regular text
colorError: "red",
colorWarn: "#9A5B00",
colorFaded: "#777777", // For less important text
fontSizeSmaller: 14,
dividerColor: "#dddddd",
strongDividerColor: "#aaaaaa",
selectedColor: '#e5e5e5',
disabledOpacity: 0.2,
colorUrl: '#7B81FF',
textSelectionColor: "#0096FF",
raisedBackgroundColor: "#0080EF",
raisedColor: "#003363",
raisedHighlightedColor: "#ffffff",
warningBackgroundColor: "#FFD08D",
// For WebView - must correspond to the properties above
htmlFontSize: '16px',
htmlColor: '#222222',
htmlBackgroundColor: 'white',
htmlDividerColor: 'rgb(230,230,230)',
htmlLinkColor: 'rgb(80,130,190)',
htmlLineHeight: '1.6em',
htmlCodeBackgroundColor: 'rgb(243, 243, 243)',
htmlCodeBorderColor: 'rgb(220, 220, 220)',
htmlCodeColor: 'rgb(0,0,0)',
codeThemeCss: 'hljs-atom-one-light.css',
};
globalStyle.marginRight = globalStyle.margin;
globalStyle.marginLeft = globalStyle.margin;
globalStyle.marginTop = globalStyle.margin;
globalStyle.marginBottom = globalStyle.margin;
globalStyle.htmlMarginLeft = ((globalStyle.marginLeft / 10) * 0.6).toFixed(2) + 'em';
let themeCache_ = {};
function addExtraStyles(style) {
style.icon = {
color: style.color,
fontSize: 30,
};
style.lineInput = {
color: style.color,
backgroundColor: style.backgroundColor,
borderBottomWidth: 1,
borderColor: style.strongDividerColor,
paddingBottom: 0,
};
if (Platform.OS === 'ios') {
delete style.lineInput.borderBottomWidth;
delete style.lineInput.borderColor;
}
style.buttonRow = {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: style.dividerColor,
paddingTop: 10,
};
style.normalText = {
color: style.color,
fontSize: style.fontSize,
};
style.urlText = {
color: style.colorUrl,
fontSize: style.fontSize,
};
return style;
}
function themeStyle(theme) {
if (!theme) {
console.warn('Theme not set!! Defaulting to Light theme');
theme = Setting.THEME_LIGHT;
}
if (themeCache_[theme]) return themeCache_[theme];
let output = Object.assign({}, globalStyle);
if (theme == Setting.THEME_LIGHT) return addExtraStyles(output);
output.backgroundColor = '#1D2024';
output.color = '#dddddd';
output.colorFaded = '#777777';
output.dividerColor = '#555555';
output.strongDividerColor = '#888888';
output.selectedColor = '#333333';
output.textSelectionColor = '#00AEFF';
output.raisedBackgroundColor = "#0F2051";
output.raisedColor = "#788BC3";
output.raisedHighlightedColor = "#ffffff";
output.htmlColor = 'rgb(220,220,220)';
output.htmlBackgroundColor = 'rgb(29,32,36)';
output.htmlLinkColor = 'rgb(166,166,255)';
output.htmlDividerColor = '#3D444E';
output.htmlLinkColor = 'rgb(166,166,255)';
output.htmlCodeColor = '#ffffff';
output.htmlCodeBackgroundColor = 'rgb(47, 48, 49)';
output.htmlCodeBorderColor = 'rgb(70, 70, 70)';
output.codeThemeCss = 'hljs-atom-one-dark-reasonable.css';
output.colorUrl = '#7B81FF';
themeCache_[theme] = output;
return addExtraStyles(themeCache_[theme]);
}
module.exports = { globalStyle, themeStyle };

View File

@ -1,189 +0,0 @@
const React = require('react'); const Component = React.Component;
const { Platform, WebView, View } = require('react-native');
const { themeStyle } = require('lib/components/global-style.js');
const Resource = require('lib/models/Resource.js');
const Setting = require('lib/models/Setting.js');
const { reg } = require('lib/registry.js');
const { shim } = require('lib/shim');
const MdToHtml = require('lib/MdToHtml.js');
const shared = require('lib/components/shared/note-screen-shared.js');
class NoteBodyViewer extends Component {
constructor() {
super();
this.state = {
resources: {},
webViewLoaded: false,
}
this.isMounted_ = false;
}
UNSAFE_componentWillMount() {
this.mdToHtml_ = new MdToHtml();
this.isMounted_ = true;
}
componentWillUnmount() {
this.mdToHtml_ = null;
this.isMounted_ = false;
}
onLoadEnd() {
setTimeout(() => {
if (this.props.onLoadEnd) this.props.onLoadEnd();
}, 100);
if (this.state.webViewLoaded) return;
// Need to display after a delay to avoid a white flash before
// the content is displayed.
setTimeout(() => {
if (!this.isMounted_) return;
this.setState({ webViewLoaded: true });
}, 100);
}
shouldComponentUpdate(nextProps, nextState) {
const safeGetNoteProp = (props, propName) => {
if (!props) return null;
if (!props.note) return null;
return props.note[propName];
}
// To address https://github.com/laurent22/joplin/issues/433
// If a checkbox in a note is ticked, the body changes, which normally would trigger a re-render
// of this component, which has the unfortunate side effect of making the view scroll back to the top.
// This re-rendering however is uncessary since the component is already visually updated via JS.
// So here, if the note has not changed, we prevent the component from updating.
// This fixes the above issue. A drawback of this is if the note is updated via sync, this change
// will not be displayed immediately.
const currentNoteId = safeGetNoteProp(this.props, 'id');
const nextNoteId = safeGetNoteProp(nextProps, 'id');
if (currentNoteId !== nextNoteId || nextState.webViewLoaded !== this.state.webViewLoaded) return true;
// If the length of the body has changed, then it's something other than a checkbox that has changed,
// for example a resource that has been attached to the note while in View mode. In that case, update.
return (safeGetNoteProp(this.props, 'body') + '').length !== (safeGetNoteProp(nextProps, 'body') + '').length;
}
rebuildMd() {
// this.mdToHtml_.clearCache();
this.forceUpdate();
}
render() {
const note = this.props.note;
const style = this.props.style;
const onCheckboxChange = this.props.onCheckboxChange;
const theme = themeStyle(this.props.theme);
const bodyToRender = note ? note.body : '';
const mdOptions = {
onResourceLoaded: () => {
if (this.resourceLoadedTimeoutId_) {
clearTimeout(this.resourceLoadedTimeoutId_);
this.resourceLoadedTimeoutId_ = null;
}
this.resourceLoadedTimeoutId_ = setTimeout(() => {
this.resourceLoadedTimeoutId_ = null;
this.forceUpdate();
}, 100);
},
paddingBottom: '3.8em', // Extra bottom padding to make it possible to scroll past the action button (so that it doesn't overlap the text)
highlightedKeywords: this.props.highlightedKeywords,
resources: this.props.noteResources,//await shared.attachedResources(bodyToRender),
codeTheme: theme.codeThemeCss,
};
let result = this.mdToHtml_.render(bodyToRender, this.props.webViewStyle, mdOptions);
let html = result.html;
const injectedJs = [this.mdToHtml_.injectedJavaScript()];
injectedJs.push(shim.injectedJs('webviewLib'));
injectedJs.push('webviewLib.initialize({ postMessage: postMessage });');
console.info(injectedJs);
html = `
<!DOCTYPE html>
<html>
<head>
</head>
<body>
` + html + `
</body>
</html>
`;
let webViewStyle = {'backgroundColor': this.props.webViewStyle.backgroundColor}
// On iOS, the onLoadEnd() event is never fired so always
// display the webview (don't do the little trick
// to avoid the white flash).
if (Platform.OS !== 'ios') {
webViewStyle.opacity = this.state.webViewLoaded ? 1 : 0.01;
}
// On iOS scalesPageToFit work like this:
//
// Find the widest image, resize it *and everything else* by x% so that
// the image fits within the viewport. The problem is that it means if there's
// a large image, everything is going to be scaled to a very small size, making
// the text unreadable.
//
// On Android:
//
// Find the widest elements and scale them (and them only) to fit within the viewport
// It means it's going to scale large images, but the text will remain at the normal
// size.
//
// That means we can use scalesPageToFix on Android but not on iOS.
// The weird thing is that on iOS, scalesPageToFix=false along with a CSS
// rule "img { max-width: 100% }", works like scalesPageToFix=true on Android.
// So we use scalesPageToFix=false on iOS along with that CSS rule.
// `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
const source = {
html: html,
baseUrl: 'file://' + Setting.value('resourceDir') + '/',
};
return (
<View style={style}>
<WebView
scalesPageToFit={Platform.OS !== 'ios'}
style={webViewStyle}
source={source}
injectedJavaScript={injectedJs.join('\n')}
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
mixedContentMode="always"
allowFileAccess={true}
onLoadEnd={() => this.onLoadEnd()}
onError={() => reg.logger().error('WebView error') }
onMessage={(event) => {
let msg = event.nativeEvent.data;
if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, this.props.note.body);
if (this.props.onCheckboxChange) this.props.onCheckboxChange(newBody);
} else if (msg.indexOf('bodyscroll:') === 0) {
//msg = msg.split(':');
//this.bodyScrollTop_ = Number(msg[1]);
} else {
this.props.onJoplinLinkClick(msg);
}
}}
/>
</View>
);
}
}
module.exports = { NoteBodyViewer };

View File

@ -1,164 +0,0 @@
const React = require('react'); const Component = React.Component;
const { connect } = require('react-redux');
const { ListView, Text, TouchableOpacity , View, StyleSheet } = require('react-native');
const { _ } = require('lib/locale.js');
const { Checkbox } = require('lib/components/checkbox.js');
const { reg } = require('lib/registry.js');
const Note = require('lib/models/Note.js');
const { time } = require('lib/time-utils.js');
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
class NoteItemComponent extends Component {
constructor() {
super();
this.styles_ = {};
}
noteItem_press(noteId) {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Note',
noteId: noteId,
});
}
styles() {
const theme = themeStyle(this.props.theme);
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
this.styles_ = {};
let styles = {
listItem: {
flexDirection: 'row',
//height: 40,
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
alignItems: 'flex-start',
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
//backgroundColor: theme.backgroundColor,
},
listItemText: {
flex: 1,
color: theme.color,
fontSize: theme.fontSize,
},
selectionWrapper: {
backgroundColor: theme.backgroundColor,
},
};
styles.listItemWithCheckbox = Object.assign({}, styles.listItem);
delete styles.listItemWithCheckbox.paddingTop;
delete styles.listItemWithCheckbox.paddingBottom;
delete styles.listItemWithCheckbox.paddingLeft;
styles.listItemTextWithCheckbox = Object.assign({}, styles.listItemText);
styles.listItemTextWithCheckbox.marginTop = styles.listItem.paddingTop - 1;
styles.listItemTextWithCheckbox.marginBottom = styles.listItem.paddingBottom;
styles.selectionWrapperSelected = Object.assign({}, styles.selectionWrapper);
styles.selectionWrapperSelected.backgroundColor = theme.selectedColor;
this.styles_[this.props.theme] = StyleSheet.create(styles);
return this.styles_[this.props.theme];
}
async todoCheckbox_change(checked) {
if (!this.props.note) return;
const newNote = {
id: this.props.note.id,
todo_completed: checked ? time.unixMs() : 0,
}
await Note.save(newNote);
}
onPress() {
if (!this.props.note) return;
if (!!this.props.note.encryption_applied) return;
if (this.props.noteSelectionEnabled) {
this.props.dispatch({
type: 'NOTE_SELECTION_TOGGLE',
id: this.props.note.id,
});
} else {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Note',
noteId: this.props.note.id,
});
}
}
onLongPress() {
if (!this.props.note) return;
this.props.dispatch({
type: this.props.noteSelectionEnabled ? 'NOTE_SELECTION_TOGGLE' : 'NOTE_SELECTION_START',
id: this.props.note.id,
});
}
render() {
const note = this.props.note ? this.props.note : {};
const isTodo = !!Number(note.is_todo);
const onPress = this.props.onPress;
const onCheckboxChange = this.props.onCheckboxChange;
const theme = themeStyle(this.props.theme);
// IOS: display: none crashes the app
let checkboxStyle = !isTodo ? { display: 'none' } : { color: theme.color };
if (isTodo) {
checkboxStyle.paddingRight = 10;
checkboxStyle.paddingTop = theme.itemMarginTop;
checkboxStyle.paddingBottom = theme.itemMarginBottom;
checkboxStyle.paddingLeft = theme.marginLeft;
}
const checkboxChecked = !!Number(note.todo_completed);
const listItemStyle = isTodo ? this.styles().listItemWithCheckbox : this.styles().listItem;
const listItemTextStyle = isTodo ? this.styles().listItemTextWithCheckbox : this.styles().listItemText;
const opacityStyle = isTodo && checkboxChecked ? {opacity: 0.4} : {};
const isSelected = this.props.noteSelectionEnabled && this.props.selectedNoteIds.indexOf(note.id) >= 0;
const selectionWrapperStyle = isSelected ? this.styles().selectionWrapperSelected : this.styles().selectionWrapper;
return (
<TouchableOpacity onPress={() => this.onPress()} onLongPress={() => this.onLongPress() } activeOpacity={0.5}>
<View style={ selectionWrapperStyle }>
<View style={ opacityStyle }>
<View style={ listItemStyle }>
<Checkbox
style={checkboxStyle}
checked={checkboxChecked}
onChange={(checked) => this.todoCheckbox_change(checked)}
/>
<Text style={listItemTextStyle}>{Note.displayTitle(note)}</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
}
}
const NoteItem = connect(
(state) => {
return {
theme: state.settings.theme,
noteSelectionEnabled: state.noteSelectionEnabled,
selectedNoteIds: state.selectedNoteIds,
};
}
)(NoteItemComponent)
module.exports = { NoteItem };

View File

@ -1,120 +0,0 @@
const React = require('react'); const Component = React.Component;
const { connect } = require('react-redux');
const { ListView, Text, TouchableHighlight, Switch, View, StyleSheet } = require('react-native');
const { _ } = require('lib/locale.js');
const { Checkbox } = require('lib/components/checkbox.js');
const { NoteItem } = require('lib/components/note-item.js');
const { reg } = require('lib/registry.js');
const Note = require('lib/models/Note.js');
const Setting = require('lib/models/Setting.js');
const { time } = require('lib/time-utils.js');
const { themeStyle } = require('lib/components/global-style.js');
class NoteListComponent extends Component {
constructor() {
super();
const ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => { return r1 !== r2; }
});
this.state = {
dataSource: ds,
items: [],
selectedItemIds: [],
};
this.rootRef_ = null;
this.styles_ = {};
}
styles() {
const themeId = this.props.theme;
const theme = themeStyle(themeId);
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
let styles = {
noItemMessage: {
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
fontSize: theme.fontSize,
color: theme.color,
},
};
this.styles_[themeId] = StyleSheet.create(styles);
return this.styles_[themeId];
}
filterNotes(notes) {
const todoFilter = 'all'; //Setting.value('todoFilter');
if (todoFilter == 'all') return notes;
const now = time.unixMs();
const maxInterval = 1000 * 60 * 60 * 24;
const notRecentTime = now - maxInterval;
let output = [];
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (note.is_todo) {
if (todoFilter == 'recent' && note.user_updated_time < notRecentTime && !!note.todo_completed) continue;
if (todoFilter == 'nonCompleted' && !!note.todo_completed) continue;
}
output.push(note);
}
return output;
}
UNSAFE_componentWillMount() {
const newDataSource = this.state.dataSource.cloneWithRows(this.filterNotes(this.props.items));
this.setState({ dataSource: newDataSource });
}
UNSAFE_componentWillReceiveProps(newProps) {
// https://stackoverflow.com/questions/38186114/react-native-redux-and-listview
this.setState({
dataSource: this.state.dataSource.cloneWithRows(this.filterNotes(newProps.items)),
});
// Make sure scroll position is reset when switching from one folder to another or to a tag list.
if (this.rootRef_ && newProps.notesSource != this.props.notesSource) {
this.rootRef_.scrollTo({ x: 0, y: 0, animated: false });
}
}
render() {
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
if (this.state.dataSource.getRowCount()) {
return (
<ListView
ref={(ref) => this.rootRef_ = ref}
dataSource={this.state.dataSource}
renderRow={(note) => {
return <NoteItem note={note}/>
}}
enableEmptySections={true}
/>
);
} else {
const noItemMessage = _('There are currently no notes. Create one by clicking on the (+) button.');
return <Text style={this.styles().noItemMessage} >{noItemMessage}</Text>;
}
}
}
const NoteList = connect(
(state) => {
return {
items: state.notes,
notesSource: state.notesSource,
theme: state.settings.theme,
noteSelectionEnabled: state.noteSelectionEnabled,
};
}
)(NoteListComponent)
module.exports = { NoteList };

View File

@ -1,506 +0,0 @@
const React = require('react'); const Component = React.Component;
const { connect } = require('react-redux');
const { Platform, View, Text, Button, StyleSheet, TouchableOpacity, Image, ScrollView, Dimensions } = require('react-native');
const Icon = require('react-native-vector-icons/Ionicons').default;
const { BackButtonService } = require('lib/services/back-button.js');
const NavService = require('lib/services/NavService.js');
const { ReportService } = require('lib/services/report.js');
const { Menu, MenuOptions, MenuOption, MenuTrigger } = require('react-native-popup-menu');
const { _ } = require('lib/locale.js');
const Setting = require('lib/models/Setting.js');
const Note = require('lib/models/Note.js');
const Folder = require('lib/models/Folder.js');
const { FileApi } = require('lib/file-api.js');
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
const { reg } = require('lib/registry.js');
const { themeStyle } = require('lib/components/global-style.js');
const { ItemList } = require('lib/components/ItemList.js');
const { Dropdown } = require('lib/components/Dropdown.js');
const { time } = require('lib/time-utils');
const RNFS = require('react-native-fs');
const { dialogs } = require('lib/dialogs.js');
const DialogBox = require('react-native-dialogbox').default;
// Rather than applying a padding to the whole bar, it is applied to each
// individual component (button, picker, etc.) so that the touchable areas
// are widder and to give more room to the picker component which has a larger
// default height.
const PADDING_V = 10;
class ScreenHeaderComponent extends Component {
constructor() {
super();
this.styles_ = {};
}
styles() {
const themeId = Setting.value('theme');
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
const theme = themeStyle(themeId);
let styleObject = {
container: {
flexDirection: 'column',
backgroundColor: theme.raisedBackgroundColor,
alignItems: 'center',
shadowColor: '#000000',
elevation: 5,
paddingTop: Platform.OS === 'ios' ? 15 : 0, // Extra padding for iOS because the top icons are there
},
divider: {
borderBottomWidth: 1,
borderColor: theme.dividerColor,
backgroundColor: "#0000ff"
},
sideMenuButton: {
flex: 1,
alignItems: 'center',
backgroundColor: theme.raisedBackgroundColor,
paddingLeft: theme.marginLeft,
paddingRight: 5,
marginRight: 2,
paddingTop: PADDING_V,
paddingBottom: PADDING_V,
},
iconButton: {
flex: 1,
backgroundColor: theme.raisedBackgroundColor,
paddingLeft: 15,
paddingRight: 15,
paddingTop: PADDING_V,
paddingBottom: PADDING_V,
},
saveButton: {
flex: 0,
flexDirection: 'row',
alignItems: 'center',
padding: 10,
borderWidth: 1,
borderColor: theme.raisedHighlightedColor,
borderRadius: 4,
marginRight: 8,
},
saveButtonText: {
textAlignVertical: 'center',
color: theme.raisedHighlightedColor,
fontWeight: 'bold',
},
savedButtonIcon: {
fontSize: 20,
color: theme.raisedHighlightedColor,
width: 18,
height: 18,
},
saveButtonIcon: {
width: 18,
height: 18,
},
contextMenuTrigger: {
fontSize: 30,
paddingLeft: 10,
paddingRight: theme.marginRight,
color: theme.raisedColor,
fontWeight: 'bold',
},
contextMenu: {
backgroundColor: theme.raisedBackgroundColor,
},
contextMenuItem: {
backgroundColor: theme.backgroundColor,
},
contextMenuItemText: {
flex: 1,
textAlignVertical: 'center',
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
color: theme.color,
backgroundColor: theme.backgroundColor,
fontSize: theme.fontSize,
},
titleText: {
flex: 1,
textAlignVertical: 'center',
marginLeft: 0,
color: theme.raisedHighlightedColor,
fontWeight: 'bold',
fontSize: theme.fontSize,
paddingTop: 15,
paddingBottom: 15,
},
warningBox: {
backgroundColor: "#ff9900",
flexDirection: 'row',
padding: theme.marginLeft,
},
};
styleObject.topIcon = Object.assign({}, theme.icon);
styleObject.topIcon.flex = 1;
styleObject.topIcon.textAlignVertical = 'center';
styleObject.topIcon.color = theme.raisedColor;
styleObject.backButton = Object.assign({}, styleObject.iconButton);
styleObject.backButton.marginRight = 1;
styleObject.backButtonDisabled = Object.assign({}, styleObject.backButton, { opacity: theme.disabledOpacity });
styleObject.saveButtonDisabled = Object.assign({}, styleObject.saveButton, { opacity: theme.disabledOpacity });
this.styles_[themeId] = StyleSheet.create(styleObject);
return this.styles_[themeId];
}
sideMenuButton_press() {
this.props.dispatch({ type: 'SIDE_MENU_TOGGLE' });
}
async backButton_press() {
await BackButtonService.back();
}
searchButton_press() {
NavService.go('Search');
}
async deleteButton_press() {
// Dialog needs to be displayed as a child of the parent component, otherwise
// it won't be visible within the header component.
const ok = await dialogs.confirm(this.props.parentComponent, _('Delete these notes?'));
if (!ok) return;
const noteIds = this.props.selectedNoteIds;
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
await Note.batchDelete(noteIds);
}
menu_select(value) {
if (typeof(value) == 'function') {
value();
}
}
log_press() {
NavService.go('Log');
}
status_press() {
NavService.go('Status');
}
config_press() {
NavService.go('Config');
}
encryptionConfig_press() {
NavService.go('EncryptionConfig');
}
warningBox_press() {
NavService.go('EncryptionConfig');
}
async debugReport_press() {
const service = new ReportService();
const logItems = await reg.logger().lastEntries(null);
const logItemRows = [
['Date','Level','Message']
];
for (let i = 0; i < logItems.length; i++) {
const item = logItems[i];
logItemRows.push([
time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss'),
item.level,
item.message
]);
}
const logItemCsv = service.csvCreate(logItemRows);
const itemListCsv = await service.basicItemList({ format: 'csv' });
const filePath = RNFS.ExternalDirectoryPath + '/syncReport-' + (new Date()).getTime() + '.txt';
const finalText = [logItemCsv, itemListCsv].join("\n================================================================================\n");
await RNFS.writeFile(filePath, finalText);
alert('Debug report exported to ' + filePath);
}
render() {
function sideMenuButton(styles, onPress) {
return (
<TouchableOpacity onPress={onPress}>
<View style={styles.sideMenuButton}>
<Icon name='md-menu' style={styles.topIcon} />
</View>
</TouchableOpacity>
);
}
function backButton(styles, onPress, disabled) {
return (
<TouchableOpacity onPress={onPress} disabled={disabled}>
<View style={disabled ? styles.backButtonDisabled : styles.backButton}>
<Icon name='md-arrow-back' style={styles.topIcon} />
</View>
</TouchableOpacity>
);
}
function saveButton(styles, onPress, disabled, show) {
if (!show) return null;
const icon = disabled ? <Icon name='md-checkmark' style={styles.savedButtonIcon} /> : <Image style={styles.saveButtonIcon} source={require('./SaveIcon.png')} />;
return (
<TouchableOpacity onPress={onPress} disabled={disabled} style={{ padding:0 }}>
<View style={disabled ? styles.saveButtonDisabled : styles.saveButton}>
{ icon }
</View>
</TouchableOpacity>
);
}
function searchButton(styles, onPress) {
return (
<TouchableOpacity onPress={onPress}>
<View style={styles.iconButton}>
<Icon name='md-search' style={styles.topIcon} />
</View>
</TouchableOpacity>
);
}
function deleteButton(styles, onPress) {
return (
<TouchableOpacity onPress={onPress}>
<View style={styles.iconButton}>
<Icon name='md-trash' style={styles.topIcon} />
</View>
</TouchableOpacity>
);
}
function sortButton(styles, onPress) {
return (
<TouchableOpacity onPress={onPress}>
<View style={styles.iconButton}>
<Icon name='md-funnel' style={styles.topIcon} />
</View>
</TouchableOpacity>
);
}
let key = 0;
let menuOptionComponents = [];
if (!this.props.noteSelectionEnabled) {
for (let i = 0; i < this.props.menuOptions.length; i++) {
let o = this.props.menuOptions[i];
if (o.isDivider) {
menuOptionComponents.push(<View key={'menuOption_' + key++} style={this.styles().divider}/>);
} else {
menuOptionComponents.push(
<MenuOption value={o.onPress} key={'menuOption_' + key++} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{o.title}</Text>
</MenuOption>);
}
}
if (this.props.showAdvancedOptions) {
if (menuOptionComponents.length) {
menuOptionComponents.push(<View key={'menuOption_showAdvancedOptions'} style={this.styles().divider}/>);
}
menuOptionComponents.push(
<MenuOption value={() => this.log_press()} key={'menuOption_log'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Log')}</Text>
</MenuOption>);
menuOptionComponents.push(
<MenuOption value={() => this.status_press()} key={'menuOption_status'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Status')}</Text>
</MenuOption>);
if (Platform.OS === 'android') {
menuOptionComponents.push(
<MenuOption value={() => this.debugReport_press()} key={'menuOption_debugReport'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Export Debug Report')}</Text>
</MenuOption>);
}
}
if (menuOptionComponents.length) {
menuOptionComponents.push(<View key={'menuOption_' + key++} style={this.styles().divider}/>);
}
menuOptionComponents.push(
<MenuOption value={() => this.encryptionConfig_press()} key={'menuOption_encryptionConfig'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Encryption Config')}</Text>
</MenuOption>);
menuOptionComponents.push(
<MenuOption value={() => this.config_press()} key={'menuOption_config'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Configuration')}</Text>
</MenuOption>);
} else {
menuOptionComponents.push(
<MenuOption value={() => this.deleteButton_press()} key={'menuOption_delete'} style={this.styles().contextMenuItem}>
<Text style={this.styles().contextMenuItemText}>{_('Delete')}</Text>
</MenuOption>);
}
const createTitleComponent = () => {
const themeId = Setting.value('theme');
const theme = themeStyle(themeId);
const folderPickerOptions = this.props.folderPickerOptions;
if (folderPickerOptions && folderPickerOptions.enabled) {
const addFolderChildren = (folders, pickerItems, indent) => {
folders.sort((a, b) => {
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
});
for (let i = 0; i < folders.length; i++) {
const f = folders[i];
pickerItems.push({ label: ' '.repeat(indent) + ' ' + Folder.displayTitle(f), value: f.id });
pickerItems = addFolderChildren(f.children, pickerItems, indent + 1);
}
return pickerItems;
}
const titlePickerItems = (mustSelect) => {
let output = [];
if (mustSelect) output.push({ label: _('Move to notebook...'), value: null });
const folderTree = Folder.buildTree(this.props.folders);
output = addFolderChildren(folderTree, output, 0);
return output;
}
return (
<Dropdown
items={titlePickerItems(!!folderPickerOptions.mustSelect)}
itemHeight={35}
labelTransform="trim"
selectedValue={('selectedFolderId' in folderPickerOptions) ? folderPickerOptions.selectedFolderId : null}
itemListStyle={{
backgroundColor: theme.backgroundColor,
}}
headerStyle={{
color: theme.raisedHighlightedColor,
fontSize: theme.fontSize,
}}
itemStyle={{
color: theme.color,
fontSize: theme.fontSize,
}}
onValueChange={async (folderId, itemIndex) => {
// If onValueChange is specified, use this as a callback, otherwise do the default
// which is to take the selectedNoteIds from the state and move them to the
// chosen folder.
if (folderPickerOptions.onValueChange) {
folderPickerOptions.onValueChange(folderId, itemIndex);
return;
}
if (!folderId) return;
const noteIds = this.props.selectedNoteIds;
if (!noteIds.length) return;
const folder = await Folder.load(folderId);
const ok = noteIds.length > 1 ? await dialogs.confirm(this.props.parentComponent, _('Move %d notes to notebook "%s"?', noteIds.length, folder.title)) : true;
if (!ok) return;
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
for (let i = 0; i < noteIds.length; i++) {
await Note.moveToFolder(noteIds[i], folderId);
}
}}
/>
);
} else {
let title = 'title' in this.props && this.props.title !== null ? this.props.title : '';
return <Text style={this.styles().titleText}>{title}</Text>
}
}
const warningComp = this.props.showMissingMasterKeyMessage ? (
<TouchableOpacity style={this.styles().warningBox} onPress={() => this.warningBox_press()} activeOpacity={0.8}>
<Text style={{flex:1}}>{_('Press to set the decryption password.')}</Text>
</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 = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
const backButtonComp = backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack);
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 = !showContextMenuButton ? null : (
<Menu onSelect={(value) => this.menu_select(value)} style={this.styles().contextMenu}>
<MenuTrigger style={{ paddingTop: PADDING_V, paddingBottom: PADDING_V }}>
<Icon name='md-more' style={this.styles().contextMenuTrigger} />
</MenuTrigger>
<MenuOptions>
<ScrollView style={{ maxHeight: windowHeight }}>
{ menuOptionComponents }
</ScrollView>
</MenuOptions>
</Menu>
);
return (
<View style={this.styles().container} >
<View style={{flexDirection:'row', alignItems: 'center'}}>
{ sideMenuComp }
{ backButtonComp }
{ saveButton(this.styles(), () => { if (this.props.onSaveButtonPress) this.props.onSaveButtonPress() }, this.props.saveButtonDisabled === true, this.props.showSaveButton === true) }
{ titleComp }
{ searchButtonComp }
{ deleteButtonComp }
{ sortButtonComp }
{ menuComp }
</View>
{ warningComp }
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
</View>
);
}
}
ScreenHeaderComponent.defaultProps = {
menuOptions: [],
};
const ScreenHeader = connect(
(state) => {
return {
historyCanGoBack: state.historyCanGoBack,
locale: state.settings.locale,
folders: state.folders,
theme: state.settings.theme,
showAdvancedOptions: state.settings.showAdvancedOptions,
noteSelectionEnabled: state.noteSelectionEnabled,
selectedNoteIds: state.selectedNoteIds,
showMissingMasterKeyMessage: state.notLoadedMasterKeys.length && state.masterKeys.length,
};
}
)(ScreenHeaderComponent)
module.exports = { ScreenHeader };

View File

@ -1,199 +0,0 @@
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 style={this.styles().tagText}>{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();
}
}
UNSAFE_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);
});
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',
},
tagText: Object.assign({}, theme.normalText),
tagCheckbox: {
marginRight: 8,
fontSize: 20,
color: theme.color,
},
newTagBox: {
flexDirection:'row',
alignItems: 'center',
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor
},
newTagBoxLabel: Object.assign({}, theme.normalText, { marginRight: 8 }),
newTagBoxInput: Object.assign({}, theme.lineInput, { flex: 1 }),
};
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 style={this.styles().newTagBoxLabel}>{_('New tags:')}</Text><TextInput selectionColor={theme.textSelectionColor} value={this.state.newTags} onChangeText={value => { this.setState({ newTags: value }) }} style={this.styles().newTagBoxInput}/>
</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;

View File

@ -1,299 +0,0 @@
const React = require('react'); const Component = React.Component;
const { Platform, TouchableOpacity, Linking, View, Switch, Slider, StyleSheet, Text, Button, ScrollView, TextInput } = require('react-native');
const { connect } = require('react-redux');
const { ScreenHeader } = require('lib/components/screen-header.js');
const { _, setLocale } = require('lib/locale.js');
const { BaseScreenComponent } = require('lib/components/base-screen.js');
const { Dropdown } = require('lib/components/Dropdown.js');
const { themeStyle } = require('lib/components/global-style.js');
const Setting = require('lib/models/Setting.js');
const shared = require('lib/components/shared/config-shared.js');
const SyncTargetRegistry = require('lib/SyncTargetRegistry');
const { reg } = require('lib/registry.js');
const VersionInfo = require('react-native-version-info').default;
class ConfigScreenComponent extends BaseScreenComponent {
static navigationOptions(options) {
return { header: null };
}
constructor() {
super();
this.styles_ = {};
shared.init(this);
this.checkSyncConfig_ = async () => {
await shared.checkSyncConfig(this, this.state.settings);
}
this.saveButton_press = () => {
return shared.saveSettings(this);
};
}
UNSAFE_componentWillMount() {
this.setState({ settings: this.props.settings });
}
styles() {
const themeId = this.props.theme;
const theme = themeStyle(themeId);
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
let styles = {
body: {
flex: 1,
justifyContent: 'flex-start',
flexDirection: 'column',
},
settingContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
},
settingText: {
fontWeight: 'bold',
color: theme.color,
fontSize: theme.fontSize,
flex: 1,
},
descriptionText: {
color: theme.color,
fontSize: theme.fontSize,
flex: 1,
},
permissionText: {
color: theme.color,
fontSize: theme.fontSize,
flex: 1,
marginTop: 10,
},
settingControl: {
color: theme.color,
flex: 1,
},
}
// if (Platform.OS === 'ios') {
styles.settingControl.borderBottomWidth = 1;
styles.settingControl.borderBottomColor = theme.strongDividerColor;
// }
styles.switchSettingText = Object.assign({}, styles.settingText);
styles.switchSettingText.width = '80%';
styles.switchSettingContainer = Object.assign({}, styles.settingContainer);
styles.switchSettingContainer.flexDirection = 'row';
styles.switchSettingContainer.justifyContent = 'space-between';
styles.linkText = Object.assign({}, styles.settingText);
styles.linkText.borderBottomWidth = 1;
styles.linkText.borderBottomColor = theme.color;
styles.linkText.flex = 0;
styles.linkText.fontWeight = 'normal';
styles.switchSettingControl = Object.assign({}, styles.settingControl);
delete styles.switchSettingControl.color;
//styles.switchSettingControl.width = '20%';
styles.switchSettingControl.flex = 0;
this.styles_[themeId] = StyleSheet.create(styles);
return this.styles_[themeId];
}
settingToComponent(key, value) {
const themeId = this.props.theme;
const theme = themeStyle(themeId);
let output = null;
const updateSettingValue = (key, value) => {
return shared.updateSettingValue(this, key, value);
}
const md = Setting.settingMetadata(key);
if (md.isEnum) {
value = value.toString();
let items = [];
const settingOptions = md.options();
for (let k in settingOptions) {
if (!settingOptions.hasOwnProperty(k)) continue;
items.push({ label: settingOptions[k], value: k.toString() });
}
return (
<View key={key} style={this.styles().settingContainer}>
<Text key="label" style={this.styles().settingText}>{md.label()}</Text>
<Dropdown
key="control"
style={this.styles().settingControl}
items={items}
selectedValue={value}
itemListStyle={{
backgroundColor: theme.backgroundColor,
}}
headerStyle={{
color: theme.color,
fontSize: theme.fontSize,
}}
itemStyle={{
color: theme.color,
fontSize: theme.fontSize,
}}
onValueChange={(itemValue, itemIndex) => { updateSettingValue(key, itemValue); }}
/>
</View>
);
} else if (md.type == Setting.TYPE_BOOL) {
return (
<View key={key} style={this.styles().switchSettingContainer}>
<Text key="label" style={this.styles().switchSettingText}>{md.label()}</Text>
<Switch key="control" style={this.styles().switchSettingControl} value={value} onValueChange={(value) => updateSettingValue(key, value)} />
</View>
);
} else if (md.type == Setting.TYPE_INT) {
const unitLabel = md.unitLabel ? md.unitLabel(value) : value;
return (
<View key={key} style={this.styles().settingContainer}>
<Text key="label" style={this.styles().settingText}>{md.label()}</Text>
<View style={{display:'flex', flexDirection: 'column', alignItems: 'center', flex:1}}>
<Slider key="control" style={{width:'100%'}} step={md.step} minimumValue={md.minimum} maximumValue={md.maximum} value={value} onValueChange={(value) => updateSettingValue(key, value)} />
<Text>{unitLabel}</Text>
</View>
</View>
);
} else if (md.type == Setting.TYPE_STRING) {
return (
<View key={key} style={this.styles().settingContainer}>
<Text key="label" style={this.styles().settingText}>{md.label()}</Text>
<TextInput selectionColor={theme.textSelectionColor} autoCapitalize="none" key="control" style={this.styles().settingControl} value={value} onChangeText={(value) => updateSettingValue(key, value)} secureTextEntry={!!md.secure} />
</View>
);
} else {
//throw new Error('Unsupported setting type: ' + md.type);
}
return output;
}
render() {
const settings = this.state.settings;
const settingComps = shared.settingsToComponents(this, 'mobile', settings);
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']);
if (syncTargetMd.supportsConfigCheck) {
const messages = shared.checkSyncConfigMessages(this);
const statusComp = !messages.length ? null : (
<View style={{flex:1, marginTop: 10}}>
<Text style={this.styles().descriptionText}>{messages[0]}</Text>
{messages.length >= 1 ? (<View style={{marginTop:10}}><Text style={this.styles().descriptionText}>{messages[1]}</Text></View>) : null}
</View>);
settingComps.push(
<View key="check_sync_config_button" style={this.styles().settingContainer}>
<View style={{flex:1, flexDirection: 'column'}}>
<View style={{flex:1}}>
<Button title={_('Check synchronisation configuration')} onPress={this.checkSyncConfig_}/>
</View>
{ statusComp }
</View>
</View>);
}
if (Platform.OS === 'android' && Platform.Version >= 23) {
// Note: `PermissionsAndroid` doesn't work so we have to ask the user to manually
// set these permissions. https://stackoverflow.com/questions/49771084/permission-always-returns-never-ask-again
settingComps.push(
<View key="permission_info" style={this.styles().settingContainer}>
<View key="permission_info_wrapper">
<Text key="perm1a" style={this.styles().settingText}>{_('To work correctly, the app needs the following permissions. Please enable them in your phone settings, in Apps > Joplin > Permissions')}</Text>
<Text key="perm2" style={this.styles().permissionText}>{_('- Storage: to allow attaching files to notes and to enable filesystem synchronisation.')}</Text>
<Text key="perm3" style={this.styles().permissionText}>{_('- Camera: to allow taking a picture and attaching it to a note.')}</Text>
<Text key="perm4" style={this.styles().permissionText}>{_('- Location: to allow attaching geo-location information to a note.')}</Text>
</View>
</View>
);
}
settingComps.push(
<View key="donate_link" style={this.styles().settingContainer}>
<TouchableOpacity onPress={() => { Linking.openURL('https://joplinapp.org/donate/') }}>
<Text key="label" style={this.styles().linkText}>{_('Make a donation')}</Text>
</TouchableOpacity>
</View>
);
settingComps.push(
<View key="website_link" style={this.styles().settingContainer}>
<TouchableOpacity onPress={() => { Linking.openURL('https://joplinapp.org/') }}>
<Text key="label" style={this.styles().linkText}>{_('Joplin website')}</Text>
</TouchableOpacity>
</View>
);
settingComps.push(
<View key="privacy_link" style={this.styles().settingContainer}>
<TouchableOpacity onPress={() => { Linking.openURL('https://joplinapp.org/privacy/') }}>
<Text key="label" style={this.styles().linkText}>Privacy Policy</Text>
</TouchableOpacity>
</View>
);
settingComps.push(
<View key="version_info_app" style={this.styles().settingContainer}>
<Text style={this.styles().settingText}>{"Joplin " + VersionInfo.appVersion}</Text>
</View>
);
settingComps.push(
<View key="version_info_db" style={this.styles().settingContainer}>
<Text style={this.styles().settingText}>{_('Database v%s', reg.db().version())}</Text>
</View>
);
settingComps.push(
<View key="version_info_fts" style={this.styles().settingContainer}>
<Text style={this.styles().settingText}>{_('FTS enabled: %d', this.props.settings['db.ftsEnabled'])}</Text>
</View>
);
return (
<View style={this.rootStyle(this.props.theme).root}>
<ScreenHeader
title={_('Configuration')}
showSaveButton={true}
saveButtonDisabled={!this.state.changedSettingKeys.length}
onSaveButtonPress={this.saveButton_press}
/>
<ScrollView >
{ settingComps }
</ScrollView>
</View>
);
}
}
const ConfigScreen = connect(
(state) => {
return {
settings: state.settings,
theme: state.settings.theme,
};
}
)(ConfigScreenComponent)
module.exports = { ConfigScreen };

Some files were not shown because too many files have changed in this diff Show More