You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
22 Commits
android-v1
...
android-v1
Author | SHA1 | Date | |
---|---|---|---|
|
beb428b246 | ||
|
4d81caff0b | ||
|
78372c9bac | ||
|
a4db1bc671 | ||
|
8ea1c373ed | ||
|
8ef27dfcdc | ||
|
23e43c7bc1 | ||
|
edb8f4c79f | ||
|
e115fa4bb3 | ||
|
52a2daddbf | ||
|
c400142996 | ||
|
219171a18c | ||
|
d7d573d9dd | ||
|
c4b17f8919 | ||
|
da2f4b96c7 | ||
|
08af9de190 | ||
|
9e2982992a | ||
|
c03ac5c5f1 | ||
|
5934f2f08e | ||
|
f136f40fdc | ||
|
d213e4ab57 | ||
|
782aae4ddf |
@@ -23,6 +23,7 @@ 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 {
|
||||
|
||||
@@ -422,6 +423,8 @@ class Application extends BaseApplication {
|
||||
const tags = await Tag.allWithNotes();
|
||||
|
||||
ResourceService.runInBackground();
|
||||
|
||||
RevisionService.instance().runInBackground();
|
||||
|
||||
this.dispatch({
|
||||
type: 'TAG_UPDATE_ALL',
|
||||
|
@@ -22,6 +22,7 @@ const Tag = require('lib/models/Tag.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const MasterKey = require('lib/models/MasterKey');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const Revision = require('lib/models/Revision.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { FsDriverNode } = require('lib/fs-driver-node.js');
|
||||
const { shimInit } = require('lib/shim-init-node.js');
|
||||
@@ -43,6 +44,7 @@ BaseItem.loadClass('Resource', Resource);
|
||||
BaseItem.loadClass('Tag', Tag);
|
||||
BaseItem.loadClass('NoteTag', NoteTag);
|
||||
BaseItem.loadClass('MasterKey', MasterKey);
|
||||
BaseItem.loadClass('Revision', Revision);
|
||||
|
||||
Setting.setConstant('appId', 'net.cozic.joplin-cli');
|
||||
Setting.setConstant('appType', 'cli');
|
||||
|
@@ -15,6 +15,8 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.0.6\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
msgstr "Pro smazání tagu jej odeberte od přiřazených poznámek."
|
||||
@@ -23,7 +25,7 @@ msgid "Please select the note or notebook to be deleted first."
|
||||
msgstr "Nejprve prosím vyberte poznámku či zápisník ke smazání."
|
||||
|
||||
msgid "Press Ctrl+D or type \"exit\" to exit the application"
|
||||
msgstr "Pro ukončení aplikace stiskněte Ctrl+D nebo napište \"exit\""
|
||||
msgstr "Stiskněte Ctrl+D nebo napište \"exit\" pro ukončení aplikace"
|
||||
|
||||
#, javascript-format
|
||||
msgid "More than one item match \"%s\". Please narrow down your query."
|
||||
@@ -124,7 +126,6 @@ msgstr "Označí to-do jako hotové."
|
||||
msgid "Note is not a to-do: \"%s\""
|
||||
msgstr "Poznámka není to-do: \"%s\""
|
||||
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Manages E2EE configuration. Commands are `enable`, `disable`, `decrypt`, "
|
||||
"`status`, `decrypt-file` and `target-status`."
|
||||
@@ -248,9 +249,8 @@ msgstr ""
|
||||
"Pro pohyb v seznamech a textových polích (včetně této konzole) používejte "
|
||||
"šipky a page up/down."
|
||||
|
||||
#, fuzzy
|
||||
msgid "To maximise/minimise the console, press \"tc\"."
|
||||
msgstr "Pro maximalizaci/minimalizaci konzole stiskněte \"TC\"."
|
||||
msgstr "Pro maximalizaci/minimalizaci konzole stiskněte \"tc\"."
|
||||
|
||||
msgid "To enter command line mode, press \":\""
|
||||
msgstr "Pro přepnutí do příkazové řádky stiskněte \":\""
|
||||
@@ -416,12 +416,16 @@ msgstr "Autentizace nebyla dokončena (nedostali jsme autentizační token)"
|
||||
msgid ""
|
||||
"To allow Joplin to synchronise with Dropbox, please follow the steps below:"
|
||||
msgstr ""
|
||||
"Chcete-li povolit synchronizaci služby Joplin se službou Dropbox, postupujte "
|
||||
"podle následujících kroků:"
|
||||
|
||||
msgid "Step 1: Open this URL in your browser to authorise the application:"
|
||||
msgstr ""
|
||||
"Krok 1: Chcete-li povolit aplikaci, otevřete tuto adresu URL ve svém "
|
||||
"prohlížeči:"
|
||||
|
||||
msgid "Step 2: Enter the code provided by Dropbox:"
|
||||
msgstr ""
|
||||
msgstr "Krok 2: Zadejte kód poskytnutý službou Dropbox:"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Not authentified with %s. Please provide any missing credentials."
|
||||
@@ -450,20 +454,20 @@ msgid "Starting synchronisation..."
|
||||
msgstr "Zahajuji synchronizaci..."
|
||||
|
||||
msgid "Downloading resources..."
|
||||
msgstr ""
|
||||
msgstr "Stahování zdrojů..."
|
||||
|
||||
msgid "Cancelling... Please wait."
|
||||
msgstr "Zastavuji, chvíli strpení."
|
||||
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"<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)."
|
||||
msgstr ""
|
||||
"<tag-command> může být \"add\", \"remove\" nebo \"list\" - přidat (add) či "
|
||||
"<tag-command> může být \"add\", \"remove\" nebo \"list\" - přidat (add) či "
|
||||
"odebrat (remove) [tag] k [poznámce], nebo vypsat (list) seznam poznámek "
|
||||
"přiřazených k [tagu]. Příkaz `tag list` vypíše všechny tagy."
|
||||
"přiřazených k [tagu]. Příkaz `tag list` vypíše všechny tagy. S parametrem -l "
|
||||
"pro dlouhý výpis."
|
||||
|
||||
#, javascript-format
|
||||
msgid "Invalid command: \"%s\""
|
||||
@@ -516,7 +520,7 @@ msgid "Possible keys/values:"
|
||||
msgstr "Možné klíče/hodnoty:"
|
||||
|
||||
msgid "Type `joplin help` for usage information."
|
||||
msgstr "Pro nápovědu zadejte `joplin help`"
|
||||
msgstr "Zadejte `joplin help` pro nápovědu."
|
||||
|
||||
msgid "Fatal error:"
|
||||
msgstr "Fatální chyba:"
|
||||
@@ -573,18 +577,17 @@ msgid "Exporting to \"%s\" as \"%s\" format. Please wait..."
|
||||
msgstr "Exportuji do \"%s\" jako formát \"%s\". Chvíli strpení..."
|
||||
|
||||
msgid "Sidebar"
|
||||
msgstr ""
|
||||
msgstr "Postranní lišta"
|
||||
|
||||
msgid "Note list"
|
||||
msgstr ""
|
||||
msgstr "Seznam položek"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Note title"
|
||||
msgstr "Název zápisníku:"
|
||||
msgstr "Název zápisníku"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Note body"
|
||||
msgstr "Zápisníky"
|
||||
msgstr "Zápisník"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Importing from \"%s\" as \"%s\" format. Please wait..."
|
||||
@@ -615,7 +618,7 @@ msgid "Encryption options"
|
||||
msgstr "Nastavení šifrování"
|
||||
|
||||
msgid "Web clipper options"
|
||||
msgstr ""
|
||||
msgstr "Web clipper volby"
|
||||
|
||||
#, javascript-format
|
||||
msgid "%s %s (%s, %s)"
|
||||
@@ -629,7 +632,7 @@ msgid "About Joplin"
|
||||
msgstr "O aplikaci Joplin"
|
||||
|
||||
msgid "Preferences..."
|
||||
msgstr ""
|
||||
msgstr "Nastavení..."
|
||||
|
||||
msgid "Check for updates..."
|
||||
msgstr "Zkontrolovat updaty..."
|
||||
@@ -663,38 +666,36 @@ msgstr "Vyjmout"
|
||||
msgid "Paste"
|
||||
msgstr "Vložit"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select all"
|
||||
msgstr "Vybrat datum"
|
||||
msgstr "Vybrat vše"
|
||||
|
||||
msgid "Bold"
|
||||
msgstr ""
|
||||
msgstr "Tučně"
|
||||
|
||||
msgid "Italic"
|
||||
msgstr ""
|
||||
msgstr "Kurzíva"
|
||||
|
||||
msgid "Link"
|
||||
msgstr ""
|
||||
msgstr "Odkaz"
|
||||
|
||||
msgid "Insert Date Time"
|
||||
msgstr ""
|
||||
msgstr "Vložit datum a čas"
|
||||
|
||||
msgid "Edit in external editor"
|
||||
msgstr ""
|
||||
msgstr "Upravit externím editorem"
|
||||
|
||||
msgid "Search in all the notes"
|
||||
msgstr "Hledat ve všech poznámkách"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Search in current note"
|
||||
msgstr "Hledat ve všech poznámkách"
|
||||
msgstr "Hledat v aktuální poznámce"
|
||||
|
||||
#, fuzzy
|
||||
msgid "&View"
|
||||
msgstr "Zobrazit"
|
||||
|
||||
msgid "Toggle sidebar"
|
||||
msgstr ""
|
||||
msgstr "Přepnout postranní lištu"
|
||||
|
||||
msgid "Toggle editor layout"
|
||||
msgstr "Změňit layout editoru"
|
||||
@@ -735,18 +736,18 @@ msgstr "Současná verze je aktuální."
|
||||
|
||||
#, javascript-format
|
||||
msgid "%s (pre-release)"
|
||||
msgstr ""
|
||||
msgstr "%s (pre-release)"
|
||||
|
||||
msgid "An update is available, do you want to download it now?"
|
||||
msgstr "Je k dispozici update, chcete jej stáhnout?"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Your version: %s"
|
||||
msgstr ""
|
||||
msgstr "Vaše verze: %s"
|
||||
|
||||
#, javascript-format
|
||||
msgid "New version: %s"
|
||||
msgstr ""
|
||||
msgstr "Nová verze: %s"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Ano"
|
||||
@@ -755,81 +756,88 @@ msgid "No"
|
||||
msgstr "Ne"
|
||||
|
||||
msgid "Token has been copied to the clipboard!"
|
||||
msgstr ""
|
||||
msgstr "Token byl zkopírován do schránky!"
|
||||
|
||||
msgid "The web clipper service is enabled and set to auto-start."
|
||||
msgstr ""
|
||||
msgstr "Služba Web clipper je povolena a nastavena pro spouštění při startu."
|
||||
|
||||
#, javascript-format
|
||||
msgid "Status: Started on port %d"
|
||||
msgstr ""
|
||||
msgstr "Stav: Nastartováno na portu %d"
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Status: %s"
|
||||
msgstr "Stav: %s."
|
||||
msgstr "Stav: %s"
|
||||
|
||||
msgid "Disable Web Clipper Service"
|
||||
msgstr ""
|
||||
msgstr "Služba Web clipper vypnuta"
|
||||
|
||||
msgid "The web clipper service is not enabled."
|
||||
msgstr ""
|
||||
msgstr "Služba Web clipper není povolena."
|
||||
|
||||
msgid "Enable Web Clipper Service"
|
||||
msgstr ""
|
||||
msgstr "Povolit službu Web clipper"
|
||||
|
||||
msgid ""
|
||||
"Joplin Web Clipper allows saving web pages and screenshots from your browser "
|
||||
"to Joplin."
|
||||
msgstr ""
|
||||
"Joplin Web Clipper umožňuje ukládat webové stránky a screenshoty z vašeho "
|
||||
"prohlížeče do Joplin."
|
||||
|
||||
msgid "In order to use the web clipper, you need to do the following:"
|
||||
msgstr ""
|
||||
msgstr "Pro použití web clipper musíte udělat následující:"
|
||||
|
||||
msgid "Step 1: Enable the clipper service"
|
||||
msgstr ""
|
||||
msgstr "Krok 1: Zapnout službu clipper"
|
||||
|
||||
msgid ""
|
||||
"This service allows the browser extension to communicate with Joplin. When "
|
||||
"enabling it your firewall may ask you to give permission to Joplin to listen "
|
||||
"to a particular port."
|
||||
msgstr ""
|
||||
"Tato služba umožňuje rozšíření prohlížeče o komunikaci s Joplin. Pokud jej "
|
||||
"povolíte, může vás firewall požádat, abyste Joplin dali svolení naslouchat "
|
||||
"na určitém portu."
|
||||
|
||||
msgid "Step 2: Install the extension"
|
||||
msgstr ""
|
||||
msgstr "Krok 2: Nainstalovat rozšíření"
|
||||
|
||||
msgid "Download and install the relevant extension for your browser:"
|
||||
msgstr ""
|
||||
"Stáhnout a nainstalovat odpovídající rozšíření pro váš webový prohlížeč:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Advanced options"
|
||||
msgstr "Ukázat pokročilé volby"
|
||||
|
||||
msgid "Authorisation token:"
|
||||
msgstr ""
|
||||
msgstr "Autorizační token:"
|
||||
|
||||
msgid "Copy token"
|
||||
msgstr ""
|
||||
msgstr "Zkopírovat token"
|
||||
|
||||
msgid ""
|
||||
"This authorisation token is only needed to allow third-party applications to "
|
||||
"access Joplin."
|
||||
msgstr ""
|
||||
"Tento autorizační token je potřeba pouze pro povolení komunikace Joplin s "
|
||||
"aplikacemi třetích stran."
|
||||
|
||||
#, javascript-format
|
||||
msgid "Notes and settings are stored in: %s"
|
||||
msgstr "Poznámky a nastavení uloženo v: %s"
|
||||
|
||||
msgid "Browse..."
|
||||
msgstr ""
|
||||
msgstr "Procházet..."
|
||||
|
||||
msgid "Check synchronisation configuration"
|
||||
msgstr "Zkontrolujte nastavení synchronizace"
|
||||
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
msgstr "Použít"
|
||||
|
||||
msgid "Submit"
|
||||
msgstr ""
|
||||
msgstr "Odeslat"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Uložit"
|
||||
@@ -907,6 +915,8 @@ msgid ""
|
||||
"For more information about End-To-End Encryption (E2EE) and advices on how "
|
||||
"to enable it please check the documentation:"
|
||||
msgstr ""
|
||||
"Pro více informací o End-To-End šifrování (E2EE) a návod jak je povolit "
|
||||
"náhledněte do dokumentace:"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
@@ -914,7 +924,6 @@ msgstr "Status"
|
||||
msgid "Encryption is:"
|
||||
msgstr "Šifrování je:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Usage"
|
||||
msgstr "Použití: %s"
|
||||
|
||||
@@ -944,9 +953,8 @@ msgstr "Tagy oddělujte čárkami."
|
||||
msgid "Rename notebook:"
|
||||
msgstr "Přejmenovat zápisník:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Rename tag:"
|
||||
msgstr "Přejmenovat"
|
||||
msgstr "Přejmenovat tag:"
|
||||
|
||||
msgid "Set alarm:"
|
||||
msgstr "Nastavit alarm:"
|
||||
@@ -954,9 +962,8 @@ msgstr "Nastavit alarm:"
|
||||
msgid "Layout"
|
||||
msgstr "Layout"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Search..."
|
||||
msgstr "Hledání"
|
||||
msgstr "Hledat..."
|
||||
|
||||
msgid "Some items cannot be synchronised."
|
||||
msgstr "Některé položky nelze synchronizovat."
|
||||
@@ -978,32 +985,32 @@ msgid ""
|
||||
msgstr "Nemáte žádný zápisník. Vytvořte jeden kliknutím na \"Nový zápisník\"."
|
||||
|
||||
msgid "Location"
|
||||
msgstr ""
|
||||
msgstr "Lokace"
|
||||
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
msgstr "URL"
|
||||
|
||||
msgid "Note properties"
|
||||
msgstr ""
|
||||
msgstr "Nastavení poznámek"
|
||||
|
||||
msgid "Open..."
|
||||
msgstr "Otevřít..."
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "This file could not be opened: %s"
|
||||
msgstr "Nebylo možné uložit zápisník: %s"
|
||||
msgstr "Soubor se nepodařilo otevřít: %s"
|
||||
|
||||
msgid "Save as..."
|
||||
msgstr "Uložit jako..."
|
||||
|
||||
msgid "Copy path to clipboard"
|
||||
msgstr ""
|
||||
msgstr "Kopírovat cestu do schránky"
|
||||
|
||||
msgid "Copy Link Address"
|
||||
msgstr ""
|
||||
msgstr "Kopírovat adresu odkazu"
|
||||
|
||||
msgid "This attachment is not downloaded or not decrypted yet."
|
||||
msgstr ""
|
||||
msgstr "Tato příloha není ještě stažena nebo rozšifrována."
|
||||
|
||||
#, javascript-format
|
||||
msgid "Unsupported link or message: %s"
|
||||
@@ -1017,18 +1024,19 @@ msgstr "Tato poznámka je prázdný. Klikněte na \"%s\" pro otevření editoru
|
||||
|
||||
msgid "Only one note can be printed or exported to PDF at a time."
|
||||
msgstr ""
|
||||
"Pouze jedna poznámka může být zároveň vytištěna nebo exportována do PDF."
|
||||
|
||||
msgid "strong text"
|
||||
msgstr ""
|
||||
msgstr "tučný text"
|
||||
|
||||
msgid "emphasized text"
|
||||
msgstr ""
|
||||
msgstr "zvýrazněný text"
|
||||
|
||||
msgid "List item"
|
||||
msgstr ""
|
||||
msgstr "Seznam položek"
|
||||
|
||||
msgid "Insert Hyperlink"
|
||||
msgstr ""
|
||||
msgstr "Vložit odkaz"
|
||||
|
||||
msgid "Attach file"
|
||||
msgstr "Přiložit soubor"
|
||||
@@ -1044,32 +1052,32 @@ msgid "In: %s"
|
||||
msgstr "%s: %s"
|
||||
|
||||
msgid "Hyperlink"
|
||||
msgstr ""
|
||||
msgstr "Odkaz"
|
||||
|
||||
msgid "Code"
|
||||
msgstr ""
|
||||
msgstr "Kód"
|
||||
|
||||
msgid "Numbered List"
|
||||
msgstr ""
|
||||
msgstr "Číslovanáý seznam"
|
||||
|
||||
msgid "Bulleted List"
|
||||
msgstr ""
|
||||
msgstr "Seznam s odrážkami"
|
||||
|
||||
msgid "Checkbox"
|
||||
msgstr ""
|
||||
msgstr "Zaškrtávací pole"
|
||||
|
||||
msgid "Heading"
|
||||
msgstr ""
|
||||
msgstr "Nadpis"
|
||||
|
||||
msgid "Horizontal Rule"
|
||||
msgstr ""
|
||||
msgstr "Horizontální čára"
|
||||
|
||||
msgid "Click to stop external editing"
|
||||
msgstr ""
|
||||
msgstr "Kliknutím ukončíte externí úpravy"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Watching..."
|
||||
msgstr "Zastavuji..."
|
||||
msgstr "Sleduji..."
|
||||
|
||||
msgid "to-do"
|
||||
msgstr "to-do"
|
||||
@@ -1091,7 +1099,7 @@ msgid "OneDrive Login"
|
||||
msgstr "Přihlášení s OneDrive"
|
||||
|
||||
msgid "Dropbox Login"
|
||||
msgstr ""
|
||||
msgstr "Dropbox přihlášení"
|
||||
|
||||
msgid "Options"
|
||||
msgstr "Nastavení"
|
||||
@@ -1113,9 +1121,9 @@ msgid ""
|
||||
"All notes and sub-notebooks within this notebook will also be deleted."
|
||||
msgstr "Smazat zápisník? Budou smazány i všechny poznámky v něm obsažené."
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Remove tag \"%s\" from all notes?"
|
||||
msgstr "Odebrat tento tag ze všech poznámek?"
|
||||
msgstr "Odebrat tag \"%s\" ze všech poznámek?"
|
||||
|
||||
msgid "Remove this search from the sidebar?"
|
||||
msgstr "Smazat tento hledaný výraz z panelu?"
|
||||
@@ -1129,13 +1137,13 @@ msgstr "Přejmenovat"
|
||||
msgid "Notebooks"
|
||||
msgstr "Zápisníky"
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Decrypting items: %d/%d"
|
||||
msgstr "Získané položky: %d/%d."
|
||||
msgstr "Rozšifrované položky: %d/%d"
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Fetching resources: %d"
|
||||
msgstr "Zdroje: %d."
|
||||
msgstr "Stahování zdrojů: %d"
|
||||
|
||||
msgid "Please select where the sync status should be exported to"
|
||||
msgstr "Prosím vyberte, kam má být stav synchronizace exportován"
|
||||
@@ -1143,13 +1151,12 @@ msgstr "Prosím vyberte, kam má být stav synchronizace exportován"
|
||||
msgid "Add or remove tags"
|
||||
msgstr "Přidat či odebrat tagy"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Duplicate"
|
||||
msgstr "Ukončí aplikaci."
|
||||
msgstr "Duplikovat"
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "%s - Copy"
|
||||
msgstr "Kopírovat"
|
||||
msgstr "%s - Kopírovat"
|
||||
|
||||
msgid "Switch between note and to-do type"
|
||||
msgstr "Přepnout mezi poznámkou a to-do"
|
||||
@@ -1166,21 +1173,23 @@ msgstr "Přepnout mezi poznámkou a to-do"
|
||||
msgid "Copy Markdown link"
|
||||
msgstr "Markdown"
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Delete note \"%s\"?"
|
||||
msgstr "Smazat poznámky?"
|
||||
msgstr "Smazat poznámku \"%s\"?"
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Delete these %d notes?"
|
||||
msgstr "Smazat tyto poznámky?"
|
||||
msgstr "Smazat tyto \"%d\" poznámky?"
|
||||
|
||||
msgid ""
|
||||
"Type a note title to jump to it. Or type # followed by a tag name, or @ "
|
||||
"followed by a notebook name."
|
||||
msgstr ""
|
||||
"Zadejte název poznámky, na kterou chcete přeskočit. Nebo zadejte # "
|
||||
"následovaný názvem značky/tagu nebo @ následovaným názvem poznámkového bloku."
|
||||
|
||||
msgid "Goto Anything..."
|
||||
msgstr ""
|
||||
msgstr "Přejít kamkoliv..."
|
||||
|
||||
#, javascript-format
|
||||
msgid "Usage: %s"
|
||||
@@ -1191,7 +1200,7 @@ msgid "Unknown flag: %s"
|
||||
msgstr "Neznámý flag: %s"
|
||||
|
||||
msgid "Dropbox"
|
||||
msgstr ""
|
||||
msgstr "Dropbox"
|
||||
|
||||
msgid "File system"
|
||||
msgstr "Souborový systém"
|
||||
@@ -1221,7 +1230,7 @@ msgid ""
|
||||
"synchronisation again may fix the problem."
|
||||
msgstr ""
|
||||
"Nelze obnovit token: chybí autentizační data. Restart synchronizace může "
|
||||
"tento problém vyřešit. "
|
||||
"tento problém vyřešit."
|
||||
|
||||
msgid "Untitled"
|
||||
msgstr "Bez názvu"
|
||||
@@ -1300,28 +1309,24 @@ msgstr "Zašifrováno"
|
||||
msgid "Encrypted items cannot be modified"
|
||||
msgstr "Nelze editovat zašifrovanou položku"
|
||||
|
||||
#, fuzzy
|
||||
msgid "title"
|
||||
msgstr "Bez názvu"
|
||||
msgstr "bez názvu"
|
||||
|
||||
#, fuzzy
|
||||
msgid "updated date"
|
||||
msgstr "Upraveno: %d."
|
||||
msgstr "upraveno: %d"
|
||||
|
||||
msgid "Conflicts"
|
||||
msgstr "Konflikty"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Cannot move notebook to this location"
|
||||
msgstr "Poznámku nelze přesunout do zápisníku \"%s\""
|
||||
msgstr "Poznámku nelze přesunout do zápisníku"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Notebooks cannot be named \"%s\", which is a reserved title."
|
||||
msgstr "Zápisník se nemůže jmenovat \"%s\", tento název je rezervován."
|
||||
|
||||
#, fuzzy
|
||||
msgid "created date"
|
||||
msgstr "Vytvořeno: %d."
|
||||
msgstr "vytvořeno: %d"
|
||||
|
||||
msgid "This note does not have geolocation information."
|
||||
msgstr "Tato poznámka nemá informace o poloze."
|
||||
@@ -1332,7 +1337,7 @@ msgstr "Poznámku \"%s\" nelze zkopírovat do zápisníku"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Cannot move note to \"%s\" notebook"
|
||||
msgstr "Poznámku nelze přesunout do zápisníku \"%s\""
|
||||
msgstr "Poznámku nelze přesunout do \"%s\" zápisníku"
|
||||
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
@@ -1340,6 +1345,9 @@ msgid ""
|
||||
"to it before syncing, otherwise all files will be removed! See the FAQ for "
|
||||
"more details: %s"
|
||||
msgstr ""
|
||||
"Upozornění: Pokud toto umístění změníte, před synchronizací se ujistěte, že "
|
||||
"jste do něj zkopírovali veškerý obsah, jinak budou všechny soubory "
|
||||
"odstraněny! Další podrobnosti naleznete v FAQ: % s"
|
||||
|
||||
msgid "Language"
|
||||
msgstr "Jazyk"
|
||||
@@ -1362,9 +1370,8 @@ msgstr "Tmavý"
|
||||
msgid "Uncompleted to-dos on top"
|
||||
msgstr "Nedokončené to-do listy nahoře"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Show completed to-dos"
|
||||
msgstr "Nedokončené to-do listy nahoře"
|
||||
msgstr "Zobrazit dokončené to-do listy"
|
||||
|
||||
msgid "Sort notes by"
|
||||
msgstr "Řadit poznámky podle"
|
||||
@@ -1372,7 +1379,6 @@ msgstr "Řadit poznámky podle"
|
||||
msgid "Reverse sort order"
|
||||
msgstr "Řadit od konce"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Sort notebooks by"
|
||||
msgstr "Řadit poznámky podle"
|
||||
|
||||
@@ -1392,84 +1398,83 @@ msgid "When creating a new note:"
|
||||
msgstr "Při vytváření nové poznámky:"
|
||||
|
||||
msgid "Enable soft breaks"
|
||||
msgstr ""
|
||||
msgstr "Povolit měkké zalomení"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Enable math expressions"
|
||||
msgstr "Zapnout šifrování"
|
||||
msgstr "Zapnout matematické výrazy"
|
||||
|
||||
msgid "Enable ==mark== syntax"
|
||||
msgstr ""
|
||||
msgstr "Povolit ==mark== syntaxi"
|
||||
|
||||
msgid "Enable footnotes"
|
||||
msgstr ""
|
||||
msgstr "Povolit poznámky pod čarou"
|
||||
|
||||
msgid "Enable table of contents extension"
|
||||
msgstr ""
|
||||
msgstr "Povolit rozšíření pro generování Obsahu"
|
||||
|
||||
msgid "Enable ~sub~ syntax"
|
||||
msgstr ""
|
||||
msgstr "Povolit ~sub~ syntaxi"
|
||||
|
||||
msgid "Enable ^sup^ syntax"
|
||||
msgstr ""
|
||||
msgstr "Povolit ~sub~ syntaxi"
|
||||
|
||||
msgid "Enable deflist syntax"
|
||||
msgstr ""
|
||||
msgstr "Povolit deflist syntaxi"
|
||||
|
||||
msgid "Enable abbreviation syntax"
|
||||
msgstr ""
|
||||
msgstr "Povolit syntaxi zkratek"
|
||||
|
||||
msgid "Enable markdown emoji"
|
||||
msgstr ""
|
||||
msgstr "Povolit markdown emoji"
|
||||
|
||||
msgid "Enable ++insert++ syntax"
|
||||
msgstr ""
|
||||
msgstr "Povolit ++insert++ syntaxi"
|
||||
|
||||
msgid "Enable multimarkdown table extension"
|
||||
msgstr ""
|
||||
msgstr "Povolit rozšíření multimarkdown tabulky"
|
||||
|
||||
msgid "Show tray icon"
|
||||
msgstr "Zobrazovat ikonu v panelu"
|
||||
|
||||
msgid "Note: Does not work in all desktop environments."
|
||||
msgstr ""
|
||||
msgstr "Poznámka: Nefunguje v některých desktopových prostředích."
|
||||
|
||||
msgid ""
|
||||
"This will allow Joplin to run in the background. It is recommended to enable "
|
||||
"this setting so that your notes are constantly being synchronised, thus "
|
||||
"reducing the number of conflicts."
|
||||
msgstr ""
|
||||
"Toto umožní Joplin běžet na pozadí. Doporučujeme toto nastavení povolit tak, "
|
||||
"aby se vaše poznámky neustále synchronizovaly, čímž se sníží počet konfliktů."
|
||||
|
||||
msgid "Start application minimised in the tray icon"
|
||||
msgstr ""
|
||||
msgstr "Startovat aplikaci minimalizovanou do lišty ikon"
|
||||
|
||||
msgid "Global zoom percentage"
|
||||
msgstr "Globální zoom"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Editor font size"
|
||||
msgstr "Rodina písma v editoru"
|
||||
|
||||
msgid "Editor font family"
|
||||
msgstr "Rodina písma v editoru"
|
||||
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"This must be *monospace* font or it will not work properly. If the font is "
|
||||
"incorrect or empty, it will default to a generic monospace font."
|
||||
msgstr ""
|
||||
"Jméno fontu není kontrolováno. Pokud je neplatné či chybí, bude použit "
|
||||
"defaultní monospace font."
|
||||
"výchozí monospace font."
|
||||
|
||||
msgid "Automatically update the application"
|
||||
msgstr "Automaticky updatovat aplikaci"
|
||||
|
||||
msgid "Get pre-releases when checking for updates"
|
||||
msgstr ""
|
||||
msgstr "Při hledání aktualizací zahrnout beta verze"
|
||||
|
||||
#, javascript-format
|
||||
msgid "See the pre-release page for more details: %s"
|
||||
msgstr ""
|
||||
msgstr "Pro náhled beta verzí navštivte stránku: %s"
|
||||
|
||||
msgid "Synchronisation interval"
|
||||
msgstr "Interval synchronizace"
|
||||
@@ -1486,7 +1491,6 @@ msgstr "%d hodina"
|
||||
msgid "%d hours"
|
||||
msgstr "%d hodin"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Text editor command"
|
||||
msgstr "Textový editor"
|
||||
|
||||
@@ -1533,7 +1537,7 @@ msgid "WebDAV password"
|
||||
msgstr "WebDAV heslo"
|
||||
|
||||
msgid "Custom TLS certificates"
|
||||
msgstr ""
|
||||
msgstr "Vlastní TLS certifikát"
|
||||
|
||||
msgid ""
|
||||
"Comma-separated list of paths to directories to load the certificates from, "
|
||||
@@ -1541,39 +1545,39 @@ msgid ""
|
||||
"pem. Note that if you make changes to the TLS settings, you must save your "
|
||||
"changes before clicking on \"Check synchronisation configuration\"."
|
||||
msgstr ""
|
||||
"Čárkami oddělený seznam adresářů s certifikáty nebo cest k jednotlivým "
|
||||
"souborům s certifikáty. Například: /my/cert_dir, /other/custom.pem. Uvědomte "
|
||||
"si, že pokud provedete změny nastavení TLS, musíte změny uložit dříve, než "
|
||||
"kliknete na \"Zkontrolovat konfiguraci synchronizace\"."
|
||||
|
||||
msgid "Ignore TLS certificate errors"
|
||||
msgstr ""
|
||||
msgstr "Ignorovat chyby TLS certifikátu"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Invalid option value: \"%s\". Possible values are: %s."
|
||||
msgstr "Neplatná hodnota: \"%s\". Přípustné hodnoty jsou: %s."
|
||||
|
||||
#, fuzzy
|
||||
msgid "General"
|
||||
msgstr "Obecná nastavení"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Synchronisation"
|
||||
msgstr "Stav synchronizace"
|
||||
msgstr "Synchronizace"
|
||||
|
||||
msgid "Appearance"
|
||||
msgstr ""
|
||||
msgstr "Vzhled"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Note"
|
||||
msgstr "Zápisníky"
|
||||
|
||||
msgid "Plugins"
|
||||
msgstr ""
|
||||
msgstr "Rozšíření"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Application"
|
||||
msgstr "Ukončí aplikaci."
|
||||
msgstr "Aplikace"
|
||||
|
||||
#, javascript-format
|
||||
msgid "The tag \"%s\" already exists. Please choose a different name."
|
||||
msgstr ""
|
||||
msgstr "Tag \"%s\" již existuje. Zvolte jiný název."
|
||||
|
||||
msgid "Joplin Export File"
|
||||
msgstr "Soubor Joplin Export"
|
||||
@@ -1587,9 +1591,8 @@ msgstr "Složka pro export"
|
||||
msgid "Evernote Export File"
|
||||
msgstr "Soubor Evernote Exportu"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Json Export Directory"
|
||||
msgstr "Složka pro export"
|
||||
msgstr "Složka pro JSON export"
|
||||
|
||||
msgid "File"
|
||||
msgstr "Soubor"
|
||||
@@ -1669,10 +1672,10 @@ msgid "On %s: %s"
|
||||
msgstr "Na %s: %s"
|
||||
|
||||
msgid "Permission to use camera"
|
||||
msgstr ""
|
||||
msgstr "Oprávnění použít kameru"
|
||||
|
||||
msgid "Your permission to use your camera is required."
|
||||
msgstr ""
|
||||
msgstr "Je vyžadováno oprávnění použít vaši kameru."
|
||||
|
||||
msgid "There are currently no notes. Create one by clicking on the (+) button."
|
||||
msgstr "Nemáte žádné poznámky. Vytvořte jednu kliknutím na tlačítko (+)."
|
||||
@@ -1702,11 +1705,9 @@ msgstr "Přesunout poznámky %d do zápisníku \"%s\"?"
|
||||
msgid "Press to set the decryption password."
|
||||
msgstr "Stiskněte pro zadání hesla k dešifrování."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Clear alarm"
|
||||
msgstr "Nastavit alarm"
|
||||
msgstr "Zrušit alarm"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Save alarm"
|
||||
msgstr "Nastavit alarm"
|
||||
|
||||
@@ -1723,18 +1724,18 @@ msgstr "Zrušit synchronizaci"
|
||||
msgid "Checking... Please wait."
|
||||
msgstr "Zastavuji, chvíli strpení."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Success! Synchronisation configuration appears to be correct."
|
||||
msgstr "Zkontrolujte nastavení synchronizace"
|
||||
msgstr "Úspěch! Nastavení synchronizace se zdá být v pořádku."
|
||||
|
||||
msgid ""
|
||||
"Error. Please check that URL, username, password, etc. are correct and that "
|
||||
"the sync target is accessible. The reported error was:"
|
||||
msgstr ""
|
||||
"Chyba. Zkontrolujte, zda jsou adresa URL, uživatelské jméno, heslo, atd. "
|
||||
"správné a zda je cíl synchronizace dostupný. Zjištěná chyba byla:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "The application has been authorised!"
|
||||
msgstr "Aplikace byla úspěšně autorizována."
|
||||
msgstr "Aplikace byla úspěšně autorizována!"
|
||||
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
@@ -1744,10 +1745,15 @@ msgid ""
|
||||
"\n"
|
||||
"Please try again."
|
||||
msgstr ""
|
||||
"Aplikaci se nepodařilo autorizovat:\\n\n"
|
||||
"\\n\n"
|
||||
"%s\\n\n"
|
||||
"\\n\n"
|
||||
"Prosím, zkuste to znovu."
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Decrypted items: %s / %s"
|
||||
msgstr "Získané položky: %d/%d."
|
||||
msgstr "Rozšifrované položky: %s/%s"
|
||||
|
||||
msgid "New tags:"
|
||||
msgstr "Nové tagy:"
|
||||
@@ -1759,35 +1765,39 @@ msgid ""
|
||||
"To work correctly, the app needs the following permissions. Please enable "
|
||||
"them in your phone settings, in Apps > Joplin > Permissions"
|
||||
msgstr ""
|
||||
"Aby aplikace fungovala správně, potřebuje následující oprávnění. Povolte je "
|
||||
"v nastavení telefonu v aplikaci Aplikace> Joplin> Oprávnění"
|
||||
|
||||
msgid ""
|
||||
"- Storage: to allow attaching files to notes and to enable filesystem "
|
||||
"synchronisation."
|
||||
msgstr ""
|
||||
"- Úložiště: umožňuje připojení souborů k poznámkám a umožňuje synchronizaci "
|
||||
"souborového systému."
|
||||
|
||||
msgid "- Camera: to allow taking a picture and attaching it to a note."
|
||||
msgstr ""
|
||||
"- Fotoaparát: umožňuje pořízení fotografie a její připojení k poznámce."
|
||||
|
||||
msgid "- Location: to allow attaching geo-location information to a note."
|
||||
msgstr ""
|
||||
msgstr "Lokace: umožňuje k poznámce připojit informaci o zeměpisné poloze."
|
||||
|
||||
msgid "Joplin website"
|
||||
msgstr "Web Joplinu"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Database v%s"
|
||||
msgstr ""
|
||||
msgstr "Databáze v%s"
|
||||
|
||||
#, fuzzy, javascript-format
|
||||
msgid "FTS enabled: %d"
|
||||
msgstr "K smazání: %d"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Login with Dropbox"
|
||||
msgstr "Přihlásit se pomocí OneDrive"
|
||||
msgstr "Přihlášení Dropbox"
|
||||
|
||||
msgid "Enter code here"
|
||||
msgstr ""
|
||||
msgstr "Zde vložte kód"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Master Key %s"
|
||||
@@ -1830,19 +1840,18 @@ msgstr "Zahodit změny"
|
||||
|
||||
#, javascript-format
|
||||
msgid "No item with ID %s"
|
||||
msgstr ""
|
||||
msgstr "Nenalezena položka s ID %s"
|
||||
|
||||
#, javascript-format
|
||||
msgid "The Joplin mobile app does not currently support this type of link: %s"
|
||||
msgstr ""
|
||||
msgstr "Mobilní aplikace Joplin aktuálně nepodporuje tento typ odkazu:% s"
|
||||
|
||||
#, javascript-format
|
||||
msgid "Unsupported image type: %s"
|
||||
msgstr "Nepodporovaný formát obrázku: %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Take photo"
|
||||
msgstr "Přiložit obrázek"
|
||||
msgstr "Přiložit foto"
|
||||
|
||||
msgid "Attach photo"
|
||||
msgstr "Přiložit obrázek"
|
||||
@@ -1851,7 +1860,7 @@ msgid "Attach any file"
|
||||
msgstr "Přiložit soubor"
|
||||
|
||||
msgid "Share"
|
||||
msgstr ""
|
||||
msgstr "Sdílet"
|
||||
|
||||
msgid "Convert to note"
|
||||
msgstr "Konvertovat na poznámku"
|
||||
@@ -1869,7 +1878,7 @@ msgid "View on map"
|
||||
msgstr "Zobrazit na map+"
|
||||
|
||||
msgid "Go to source URL"
|
||||
msgstr ""
|
||||
msgstr "Jít na zdrojovou URL"
|
||||
|
||||
msgid "Edit"
|
||||
msgstr "Upravit"
|
||||
|
7
CliClient/package-lock.json
generated
7
CliClient/package-lock.json
generated
@@ -210,7 +210,7 @@
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
|
||||
"resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
|
||||
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
|
||||
"requires": {
|
||||
"core-util-is": "~1.0.0",
|
||||
@@ -560,6 +560,11 @@
|
||||
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
|
||||
"integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E="
|
||||
},
|
||||
"diff-match-patch": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.4.tgz",
|
||||
"integrity": "sha512-Uv3SW8bmH9nAtHKaKSanOQmj2DnlH65fUpcrMdfdaOxUG02QQ4YGZ8AE7kKOMisF7UqvOlGKVYWRvezdncW9lg=="
|
||||
},
|
||||
"domexception": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
|
||||
|
@@ -33,6 +33,7 @@
|
||||
"base-64": "^0.1.0",
|
||||
"compare-version": "^0.1.2",
|
||||
"diacritics": "^1.3.0",
|
||||
"diff-match-patch": "^1.0.4",
|
||||
"es6-promise-pool": "^2.5.0",
|
||||
"follow-redirects": "^1.2.4",
|
||||
"form-data": "^2.1.4",
|
||||
|
@@ -31,6 +31,7 @@ npm test tests-build/models_Folder.js
|
||||
npm test tests-build/models_ItemChange.js
|
||||
npm test tests-build/models_Note.js
|
||||
npm test tests-build/models_Resource.js
|
||||
npm test tests-build/models_Revision.js
|
||||
npm test tests-build/models_Setting.js
|
||||
npm test tests-build/models_Tag.js
|
||||
npm test tests-build/pathUtils.js
|
||||
@@ -38,6 +39,7 @@ npm test tests-build/services_InteropService.js
|
||||
npm test tests-build/services_ResourceService.js
|
||||
npm test tests-build/services_rest_Api.js
|
||||
npm test tests-build/services_SearchEngine.js
|
||||
npm test tests-build/services_Revision.js
|
||||
npm test tests-build/StringUtils.js
|
||||
npm test tests-build/synchronizer.js
|
||||
npm test tests-build/urlUtils.js
|
@@ -25,8 +25,7 @@ describe('Encryption', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
//await setupDatabaseAndSynchronizer(2);
|
||||
//await switchClient(1);
|
||||
await switchClient(1);
|
||||
service = new EncryptionService();
|
||||
BaseItem.encryptionService_ = service;
|
||||
Setting.setValue('encryption.enabled', true);
|
||||
|
@@ -47,5 +47,20 @@ describe('models_BaseItem', function() {
|
||||
|
||||
expect('ignore_me' in unserialized).toBe(false);
|
||||
}));
|
||||
|
||||
it('should not modify title when unserializing', asyncTest(async () => {
|
||||
let folder1 = await Folder.save({ title: "" });
|
||||
let folder2 = await Folder.save({ title: "folder1" });
|
||||
|
||||
let serialized1 = await Folder.serialize(folder1);
|
||||
let unserialized1 = await Folder.unserialize(serialized1);
|
||||
|
||||
expect(unserialized1.title).toBe(folder1.title);
|
||||
|
||||
let serialized2 = await Folder.serialize(folder2);
|
||||
let unserialized2 = await Folder.unserialize(serialized2);
|
||||
|
||||
expect(unserialized2.title).toBe(folder2.title);
|
||||
}));
|
||||
|
||||
});
|
@@ -1,7 +1,7 @@
|
||||
require('app-module-path').addPath(__dirname);
|
||||
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
|
||||
const { asyncTest, fileContentEqual, revisionService, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
|
||||
const SearchEngine = require('lib/services/SearchEngine');
|
||||
const ResourceService = require('lib/services/ResourceService');
|
||||
const ItemChangeUtils = require('lib/services/ItemChangeUtils');
|
||||
@@ -34,19 +34,17 @@ describe('models_ItemChange', function() {
|
||||
const resourceService = new ResourceService();
|
||||
|
||||
await searchEngine.syncTables();
|
||||
|
||||
// If we run this now, it should not delete any change because
|
||||
// the resource service has not yet processed the change
|
||||
await ItemChangeUtils.deleteProcessedChanges();
|
||||
|
||||
expect(await ItemChange.lastChangeId()).toBe(1);
|
||||
|
||||
await resourceService.indexNoteResources();
|
||||
|
||||
// Now that the resource service has processed the change,
|
||||
// the change can be deleted.
|
||||
await ItemChangeUtils.deleteProcessedChanges();
|
||||
expect(await ItemChange.lastChangeId()).toBe(1);
|
||||
|
||||
await revisionService().collectRevisions();
|
||||
await ItemChangeUtils.deleteProcessedChanges();
|
||||
expect(await ItemChange.lastChangeId()).toBe(0);
|
||||
}));
|
||||
|
||||
|
@@ -86,5 +86,32 @@ describe('models_Note', function() {
|
||||
expect(changedNote === note1).toBe(false);
|
||||
expect(!!changedNote.is_todo).toBe(false);
|
||||
}));
|
||||
|
||||
it('should serialize and unserialize without modifying data', asyncTest(async () => {
|
||||
let folder1 = await Folder.save({ title: "folder1"});
|
||||
const testCases = [
|
||||
[ {title: '', body:'Body and no title\nSecond line\nThird Line', parent_id: folder1.id},
|
||||
'', 'Body and no title\nSecond line\nThird Line'],
|
||||
[ {title: 'Note title', body:'Body and title', parent_id: folder1.id},
|
||||
'Note title', 'Body and title'],
|
||||
[ {title: 'Title and no body', body:'', parent_id: folder1.id},
|
||||
'Title and no body', ''],
|
||||
]
|
||||
|
||||
for (let i = 0; i < testCases.length; i++) {
|
||||
const t = testCases[i];
|
||||
|
||||
const input = t[0];
|
||||
const expectedTitle = t[1];
|
||||
const expectedBody = t[1];
|
||||
|
||||
let note1 = await Note.save(input);
|
||||
let serialized = await Note.serialize(note1);
|
||||
let unserialized = await Note.unserialize( serialized);
|
||||
|
||||
expect(unserialized.title).toBe(input.title);
|
||||
expect(unserialized.body).toBe(input.body);
|
||||
}
|
||||
}));
|
||||
|
||||
});
|
71
CliClient/tests/models_Revision.js
Normal file
71
CliClient/tests/models_Revision.js
Normal file
@@ -0,0 +1,71 @@
|
||||
require('app-module-path').addPath(__dirname);
|
||||
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const Revision = require('lib/models/Revision.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const { shim } = require('lib/shim');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
describe('models_Revision', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should create patches of text and apply it', asyncTest(async () => {
|
||||
const note1 = await Note.save({ body: 'my note\nsecond line' });
|
||||
|
||||
const patch = Revision.createTextPatch(note1.body, 'my new note\nsecond line');
|
||||
const merged = Revision.applyTextPatch(note1.body, patch);
|
||||
|
||||
expect(merged).toBe('my new note\nsecond line');
|
||||
}));
|
||||
|
||||
it('should create patches of objects and apply it', asyncTest(async () => {
|
||||
const oldObject = {
|
||||
one: '123',
|
||||
two: '456',
|
||||
three: '789',
|
||||
};
|
||||
|
||||
const newObject = {
|
||||
one: '123',
|
||||
three: '999',
|
||||
}
|
||||
|
||||
const patch = Revision.createObjectPatch(oldObject, newObject);
|
||||
const merged = Revision.applyObjectPatch(oldObject, patch);
|
||||
|
||||
expect(JSON.stringify(merged)).toBe(JSON.stringify(newObject));
|
||||
}));
|
||||
|
||||
it('should move target revision to the top', asyncTest(async () => {
|
||||
const revs = [
|
||||
{ id: '123' },
|
||||
{ id: '456' },
|
||||
{ id: '789' },
|
||||
];
|
||||
|
||||
let newRevs;
|
||||
newRevs = Revision.moveRevisionToTop({ id: '456' }, revs);
|
||||
expect(newRevs[0].id).toBe('123');
|
||||
expect(newRevs[1].id).toBe('789');
|
||||
expect(newRevs[2].id).toBe('456');
|
||||
|
||||
newRevs = Revision.moveRevisionToTop({ id: '789' }, revs);
|
||||
expect(newRevs[0].id).toBe('123');
|
||||
expect(newRevs[1].id).toBe('456');
|
||||
expect(newRevs[2].id).toBe('789');
|
||||
}));
|
||||
|
||||
});
|
420
CliClient/tests/services_Revision.js
Normal file
420
CliClient/tests/services_Revision.js
Normal file
@@ -0,0 +1,420 @@
|
||||
require('app-module-path').addPath(__dirname);
|
||||
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { asyncTest, fileContentEqual, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const ItemChange = require('lib/models/ItemChange.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const Revision = require('lib/models/Revision.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const RevisionService = require('lib/services/RevisionService.js');
|
||||
const { shim } = require('lib/shim');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
describe('services_Revision', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
Setting.setValue('revisionService.intervalBetweenRevisions', 0)
|
||||
done();
|
||||
});
|
||||
|
||||
it('should create diff and rebuild notes', asyncTest(async () => {
|
||||
const service = new RevisionService();
|
||||
|
||||
const n1_v1 = await Note.save({ title: '', author: 'testing' });
|
||||
await service.collectRevisions();
|
||||
await Note.save({ id: n1_v1.id, title: 'hello', author: 'testing' });
|
||||
await service.collectRevisions();
|
||||
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome', author: '' });
|
||||
await service.collectRevisions();
|
||||
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
|
||||
expect(revisions.length).toBe(2);
|
||||
expect(revisions[1].parent_id).toBe(revisions[0].id);
|
||||
|
||||
const rev1 = await service.revisionNote(revisions, 0);
|
||||
expect(rev1.title).toBe('hello');
|
||||
expect(rev1.author).toBe('testing');
|
||||
|
||||
const rev2 = await service.revisionNote(revisions, 1);
|
||||
expect(rev2.title).toBe('hello welcome');
|
||||
expect(rev2.author).toBe('');
|
||||
|
||||
await time.sleep(0.5);
|
||||
|
||||
await service.deleteOldRevisions(400);
|
||||
const revisions2 = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
|
||||
expect(revisions2.length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should delete old revisions (1 note, 2 rev)', asyncTest(async () => {
|
||||
const service = new RevisionService();
|
||||
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await service.collectRevisions();
|
||||
await time.sleep(1);
|
||||
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
|
||||
await service.collectRevisions();
|
||||
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id)).length).toBe(2);
|
||||
|
||||
await service.deleteOldRevisions(1000);
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
|
||||
expect(revisions.length).toBe(1);
|
||||
|
||||
const rev1 = await service.revisionNote(revisions, 0);
|
||||
expect(rev1.title).toBe('hello welcome');
|
||||
}));
|
||||
|
||||
it('should delete old revisions (1 note, 3 rev)', asyncTest(async () => {
|
||||
const service = new RevisionService();
|
||||
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'one' });
|
||||
await service.collectRevisions();
|
||||
await time.sleep(1);
|
||||
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'one two' });
|
||||
await service.collectRevisions();
|
||||
await time.sleep(1);
|
||||
const n1_v3 = await Note.save({ id: n1_v1.id, title: 'one two three' });
|
||||
await service.collectRevisions();
|
||||
|
||||
{
|
||||
await service.deleteOldRevisions(2000);
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
|
||||
expect(revisions.length).toBe(2);
|
||||
|
||||
const rev1 = await service.revisionNote(revisions, 0);
|
||||
expect(rev1.title).toBe('one two');
|
||||
|
||||
const rev2 = await service.revisionNote(revisions, 1);
|
||||
expect(rev2.title).toBe('one two three');
|
||||
}
|
||||
|
||||
{
|
||||
await service.deleteOldRevisions(1000);
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
|
||||
expect(revisions.length).toBe(1);
|
||||
|
||||
const rev1 = await service.revisionNote(revisions, 0);
|
||||
expect(rev1.title).toBe('one two three');
|
||||
}
|
||||
}));
|
||||
|
||||
it('should delete old revisions (2 notes, 2 rev)', asyncTest(async () => {
|
||||
const service = new RevisionService();
|
||||
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'note 1' });
|
||||
const n2_v0 = await Note.save({ title: '' });
|
||||
const n2_v1 = await Note.save({ id: n2_v0.id, title: 'note 2' });
|
||||
await service.collectRevisions();
|
||||
await time.sleep(1);
|
||||
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'note 1 (v2)' });
|
||||
const n2_v2 = await Note.save({ id: n2_v1.id, title: 'note 2 (v2)' });
|
||||
await service.collectRevisions();
|
||||
|
||||
expect((await Revision.all()).length).toBe(4);
|
||||
|
||||
await service.deleteOldRevisions(1000);
|
||||
|
||||
{
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
|
||||
expect(revisions.length).toBe(1);
|
||||
const rev1 = await service.revisionNote(revisions, 0);
|
||||
expect(rev1.title).toBe('note 1 (v2)');
|
||||
}
|
||||
|
||||
{
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n2_v1.id);
|
||||
expect(revisions.length).toBe(1);
|
||||
const rev1 = await service.revisionNote(revisions, 0);
|
||||
expect(rev1.title).toBe('note 2 (v2)');
|
||||
}
|
||||
}));
|
||||
|
||||
it('should handle conflicts', asyncTest(async () => {
|
||||
const service = new RevisionService();
|
||||
|
||||
// A conflict happens in this case:
|
||||
// - Device 1 creates note1 (rev1)
|
||||
// - Device 2 syncs and get note1
|
||||
// - Device 1 modifies note1 (rev2)
|
||||
// - Device 2 modifies note1 (rev3)
|
||||
// When reconstructing the notes based on the revisions, we need to make sure it follow the right
|
||||
// "path". For example, to reconstruct the note at rev2 it would be:
|
||||
// rev1 => rev2
|
||||
// To reconstruct the note at rev3 it would be:
|
||||
// rev1 => rev3
|
||||
// And not, for example, rev1 => rev2 => rev3
|
||||
|
||||
const n1_v1 = await Note.save({ title: 'hello' });
|
||||
const noteId = n1_v1.id;
|
||||
const rev1 = await service.createNoteRevision_(n1_v1);
|
||||
const n1_v2 = await Note.save({ id: noteId, title: 'hello Paul' });
|
||||
const rev2 = await service.createNoteRevision_(n1_v2, rev1.id);
|
||||
const n1_v3 = await Note.save({ id: noteId, title: 'hello John' });
|
||||
const rev3 = await service.createNoteRevision_(n1_v3, rev1.id);
|
||||
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
|
||||
expect(revisions.length).toBe(3);
|
||||
expect(revisions[1].parent_id).toBe(rev1.id);
|
||||
expect(revisions[2].parent_id).toBe(rev1.id);
|
||||
|
||||
const revNote1 = await service.revisionNote(revisions, 0);
|
||||
const revNote2 = await service.revisionNote(revisions, 1);
|
||||
const revNote3 = await service.revisionNote(revisions, 2);
|
||||
expect(revNote1.title).toBe('hello');
|
||||
expect(revNote2.title).toBe('hello Paul');
|
||||
expect(revNote3.title).toBe('hello John');
|
||||
}));
|
||||
|
||||
it('should create a revision for notes that are older than a given interval', asyncTest(async () => {
|
||||
const n1 = await Note.save({ title: 'hello' });
|
||||
const noteId = n1.id;
|
||||
|
||||
await sleep(0.1);
|
||||
|
||||
// Set the interval in such a way that the note is considered an old one.
|
||||
Setting.setValue('revisionService.oldNoteInterval', 50);
|
||||
|
||||
// A revision is created the first time a note is overwritten with new content, and
|
||||
// if this note doesn't already have an existing revision.
|
||||
// This is mostly to handle old notes that existed before the revision service. If these
|
||||
// old notes are changed, there's a chance it's accidental or due to some bug, so we
|
||||
// want to preserve a revision just in case.
|
||||
|
||||
{
|
||||
await Note.save({ id: noteId, title: 'hello 2' });
|
||||
await revisionService().collectRevisions(); // Rev for old note created + Rev for new note
|
||||
const all = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
|
||||
expect(all.length).toBe(2);
|
||||
const revNote1 = await revisionService().revisionNote(all, 0);
|
||||
const revNote2 = await revisionService().revisionNote(all, 1);
|
||||
expect(revNote1.title).toBe('hello');
|
||||
expect(revNote2.title).toBe('hello 2');
|
||||
}
|
||||
|
||||
// If the note is saved a third time, we don't automatically create a revision. One
|
||||
// will be created x minutes later when the service collects revisions.
|
||||
|
||||
{
|
||||
await Note.save({ id: noteId, title: 'hello 3' });
|
||||
const all = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
|
||||
expect(all.length).toBe(2);
|
||||
}
|
||||
}));
|
||||
|
||||
it('should create a revision for notes that get deleted (recyle bin)', asyncTest(async () => {
|
||||
const n1 = await Note.save({ title: 'hello' });
|
||||
const noteId = n1.id;
|
||||
|
||||
await Note.delete(noteId);
|
||||
|
||||
await revisionService().collectRevisions();
|
||||
|
||||
const all = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
|
||||
expect(all.length).toBe(1);
|
||||
const rev1 = await revisionService().revisionNote(all, 0);
|
||||
expect(rev1.title).toBe('hello');
|
||||
}));
|
||||
|
||||
it('should not create a revision for notes that get deleted if there is already a revision', asyncTest(async () => {
|
||||
const n1 = await Note.save({ title: 'hello' });
|
||||
await revisionService().collectRevisions();
|
||||
const noteId = n1.id;
|
||||
await Note.save({ id: noteId, title: 'hello Paul' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
|
||||
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
|
||||
|
||||
await Note.delete(noteId);
|
||||
|
||||
// At this point there is no need to create a new revision for the deleted note
|
||||
// because we already have the latest version as REV 1
|
||||
await revisionService().collectRevisions();
|
||||
|
||||
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should not create a revision for new note the first time they are saved', asyncTest(async () => {
|
||||
const n1 = await Note.save({ title: 'hello' });
|
||||
|
||||
{
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(revisions.length).toBe(0);
|
||||
}
|
||||
|
||||
await revisionService().collectRevisions();
|
||||
|
||||
{
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(revisions.length).toBe(0);
|
||||
}
|
||||
}));
|
||||
|
||||
it('should abort collecting revisions when one of them is encrypted', asyncTest(async () => {
|
||||
const n1 = await Note.save({ title: 'hello' }); // CHANGE 1
|
||||
await revisionService().collectRevisions();
|
||||
await Note.save({ id: n1.id, title: 'hello Ringo' }); // CHANGE 2
|
||||
await revisionService().collectRevisions();
|
||||
await Note.save({ id: n1.id, title: 'hello George' }); // CHANGE 3
|
||||
await revisionService().collectRevisions();
|
||||
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(revisions.length).toBe(2);
|
||||
|
||||
const encryptedRevId = revisions[0].id;
|
||||
|
||||
// Simulate receiving an encrypted revision
|
||||
await Revision.save({ id: encryptedRevId, encryption_applied: 1 });
|
||||
await Note.save({ id: n1.id, title: 'hello Paul' }); // CHANGE 4
|
||||
|
||||
await revisionService().collectRevisions();
|
||||
|
||||
// Although change 4 is a note update, check that it has not been processed
|
||||
// by the collector, due to one of the revisions being encrypted.
|
||||
expect(await ItemChange.lastChangeId()).toBe(4);
|
||||
expect(Setting.value('revisionService.lastProcessedChangeId')).toBe(3);
|
||||
|
||||
// Simulate the revision being decrypted by DecryptionService
|
||||
await Revision.save({ id: encryptedRevId, encryption_applied: 0 });
|
||||
|
||||
await revisionService().collectRevisions();
|
||||
|
||||
// Now that the revision has been decrypted, all the changes can be processed
|
||||
expect(await ItemChange.lastChangeId()).toBe(4);
|
||||
expect(Setting.value('revisionService.lastProcessedChangeId')).toBe(4);
|
||||
}));
|
||||
|
||||
it('should not delete old revisions if one of them is still encrypted (1)', asyncTest(async () => {
|
||||
// Test case 1: Two revisions and the first one is encrypted.
|
||||
// Calling deleteOldRevisions() with low TTL, which means all revisions
|
||||
// should be deleted, but they won't be due to the encrypted one.
|
||||
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
await time.sleep(0.1);
|
||||
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
|
||||
await revisionService().collectRevisions(); // REV 2
|
||||
await time.sleep(0.1);
|
||||
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
|
||||
const revisions = await Revision.all();
|
||||
await Revision.save({ id: revisions[0].id, encryption_applied: 1 });
|
||||
|
||||
await revisionService().deleteOldRevisions(0);
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
|
||||
await Revision.save({ id: revisions[0].id, encryption_applied: 0 });
|
||||
|
||||
await revisionService().deleteOldRevisions(0);
|
||||
expect((await Revision.all()).length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should not delete old revisions if one of them is still encrypted (2)', asyncTest(async () => {
|
||||
// Test case 2: Two revisions and the first one is encrypted.
|
||||
// Calling deleteOldRevisions() with higher TTL, which means the oldest
|
||||
// revision should be deleted, but it won't be due to the encrypted one.
|
||||
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
await time.sleep(0.5);
|
||||
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
|
||||
await revisionService().collectRevisions(); // REV 2
|
||||
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
|
||||
const revisions = await Revision.all();
|
||||
await Revision.save({ id: revisions[0].id, encryption_applied: 1 });
|
||||
|
||||
await revisionService().deleteOldRevisions(500);
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should not delete old revisions if one of them is still encrypted (3)', asyncTest(async () => {
|
||||
// Test case 2: Two revisions and the second one is encrypted.
|
||||
// Calling deleteOldRevisions() with higher TTL, which means the oldest
|
||||
// revision should be deleted, but it won't be due to the encrypted one.
|
||||
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
await time.sleep(0.5);
|
||||
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
|
||||
await revisionService().collectRevisions(); // REV 2
|
||||
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
|
||||
const revisions = await Revision.all();
|
||||
await Revision.save({ id: revisions[1].id, encryption_applied: 1 });
|
||||
|
||||
await revisionService().deleteOldRevisions(500);
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
|
||||
await Revision.save({ id: revisions[1].id, encryption_applied: 0 });
|
||||
|
||||
await revisionService().deleteOldRevisions(500);
|
||||
expect((await Revision.all()).length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should not create a revision if the note has not changed', asyncTest(async () => {
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
expect((await Revision.all()).length).toBe(1);
|
||||
|
||||
const n1_v2 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // Note has not changed (except its timestamp) so don't create a revision
|
||||
expect((await Revision.all()).length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should preserve user update time', asyncTest(async () => {
|
||||
// user_updated_time is kind of tricky and can be changed automatically in various
|
||||
// places so make sure it is saved correctly with the revision
|
||||
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
expect((await Revision.all()).length).toBe(1);
|
||||
|
||||
const userUpdatedTime = Date.now() - 1000 * 60 * 60;
|
||||
const n1_v2 = await Note.save({ id: n1_v0.id, title: 'hello', updated_time: Date.now(), user_updated_time: userUpdatedTime }, { autoTimestamp: false });
|
||||
await revisionService().collectRevisions(); // Only the user timestamp has changed, but that needs to be saved
|
||||
|
||||
const revisions = await Revision.all();
|
||||
expect(revisions.length).toBe(2);
|
||||
|
||||
const revNote = await revisionService().revisionNote(revisions, 1);
|
||||
expect(revNote.user_updated_time).toBe(userUpdatedTime);
|
||||
}));
|
||||
|
||||
it('should not create a revision if there is already a recent one', asyncTest(async () => {
|
||||
const n1_v0 = await Note.save({ title: '' });
|
||||
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
|
||||
const n1_v2 = await Note.save({ id: n1_v0.id, title: 'hello 2' });
|
||||
await revisionService().collectRevisions(); // REV 2
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
|
||||
Setting.setValue('revisionService.intervalBetweenRevisions', 1000);
|
||||
|
||||
const n1_v3 = await Note.save({ id: n1_v0.id, title: 'hello 3' });
|
||||
await revisionService().collectRevisions(); // No rev because there's already a rev that is less than 1000 ms old
|
||||
|
||||
expect((await Revision.all()).length).toBe(2);
|
||||
}));
|
||||
|
||||
});
|
@@ -1,7 +1,7 @@
|
||||
require('app-module-path').addPath(__dirname);
|
||||
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { setupDatabase, allSyncTargetItemsEncrypted, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, checkThrowAsync, asyncTest } = require('test-utils.js');
|
||||
const { setupDatabase, allSyncTargetItemsEncrypted, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, checkThrowAsync, asyncTest } = require('test-utils.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const fs = require('fs-extra');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
@@ -13,6 +13,7 @@ const { Database } = require('lib/database.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const MasterKey = require('lib/models/MasterKey');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const Revision = require('lib/models/Revision.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||
const WelcomeUtils = require('lib/WelcomeUtils');
|
||||
@@ -23,19 +24,40 @@ process.on('unhandledRejection', (reason, p) => {
|
||||
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000 + 30000; // The first test is slow because the database needs to be built
|
||||
|
||||
async function allItems() {
|
||||
async function allNotesFolders() {
|
||||
let folders = await Folder.all();
|
||||
let notes = await Note.all();
|
||||
return folders.concat(notes);
|
||||
}
|
||||
|
||||
async function localItemsSameAsRemote(locals, expect) {
|
||||
async function remoteItemsByTypes(types) {
|
||||
const list = await fileApi().list();
|
||||
if (list.has_more) throw new Error('Not implemented!!!');
|
||||
const files = list.items;
|
||||
|
||||
const output = [];
|
||||
for (const file of files) {
|
||||
const remoteContent = await fileApi().get(file.path);
|
||||
const content = await BaseItem.unserialize(remoteContent);
|
||||
if (types.indexOf(content.type_) < 0) continue;
|
||||
output.push(content);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function remoteNotesAndFolders() {
|
||||
return remoteItemsByTypes([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER]);
|
||||
}
|
||||
|
||||
async function remoteNotesFoldersResources() {
|
||||
return remoteItemsByTypes([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER, BaseModel.TYPE_RESOURCE]);
|
||||
}
|
||||
|
||||
async function localNotesFoldersSameAsRemote(locals, expect) {
|
||||
let error = null;
|
||||
try {
|
||||
let files = await fileApi().list();
|
||||
files = files.items;
|
||||
|
||||
expect(locals.length).toBe(files.length);
|
||||
const nf = await remoteNotesAndFolders();
|
||||
expect(locals.length).toBe(nf.length);
|
||||
|
||||
for (let i = 0; i < locals.length; i++) {
|
||||
let dbItem = locals[i];
|
||||
@@ -45,12 +67,6 @@ async function localItemsSameAsRemote(locals, expect) {
|
||||
expect(!!remote).toBe(true);
|
||||
if (!remote) continue;
|
||||
|
||||
// if (syncTargetId() == SyncTargetRegistry.nameToId('filesystem')) {
|
||||
// expect(remote.updated_time).toBe(Math.floor(dbItem.updated_time / 1000) * 1000);
|
||||
// } else {
|
||||
// expect(remote.updated_time).toBe(dbItem.updated_time);
|
||||
// }
|
||||
|
||||
let remoteContent = await fileApi().get(path);
|
||||
|
||||
remoteContent = dbItem.type_ == BaseModel.TYPE_NOTE ? await Note.unserialize(remoteContent) : await Folder.unserialize(remoteContent);
|
||||
@@ -82,11 +98,11 @@ describe('Synchronizer', function() {
|
||||
let folder = await Folder.save({ title: "folder1" });
|
||||
await Note.save({ title: "un", parent_id: folder.id });
|
||||
|
||||
let all = await allItems();
|
||||
let all = await allNotesFolders();
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
await localItemsSameAsRemote(all, expect);
|
||||
await localNotesFoldersSameAsRemote(all, expect);
|
||||
}));
|
||||
|
||||
it('should update remote items', asyncTest(async () => {
|
||||
@@ -96,10 +112,10 @@ describe('Synchronizer', function() {
|
||||
|
||||
await Note.save({ title: "un UPDATE", id: note.id });
|
||||
|
||||
let all = await allItems();
|
||||
let all = await allNotesFolders();
|
||||
await synchronizer().start();
|
||||
|
||||
await localItemsSameAsRemote(all, expect);
|
||||
await localNotesFoldersSameAsRemote(all, expect);
|
||||
}));
|
||||
|
||||
it('should create local items', asyncTest(async () => {
|
||||
@@ -111,9 +127,9 @@ describe('Synchronizer', function() {
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
let all = await allItems();
|
||||
let all = await allNotesFolders();
|
||||
|
||||
await localItemsSameAsRemote(all, expect);
|
||||
await localNotesFoldersSameAsRemote(all, expect);
|
||||
}));
|
||||
|
||||
it('should update local items', asyncTest(async () => {
|
||||
@@ -138,9 +154,9 @@ describe('Synchronizer', function() {
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
let all = await allItems();
|
||||
let all = await allNotesFolders();
|
||||
|
||||
await localItemsSameAsRemote(all, expect);
|
||||
await localNotesFoldersSameAsRemote(all, expect);
|
||||
}));
|
||||
|
||||
it('should resolve note conflicts', asyncTest(async () => {
|
||||
@@ -232,11 +248,9 @@ describe('Synchronizer', function() {
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
let files = await fileApi().list();
|
||||
files = files.items;
|
||||
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0].path).toBe(Folder.systemPath(folder1));
|
||||
const remotes = await remoteNotesAndFolders();
|
||||
expect(remotes.length).toBe(1);
|
||||
expect(remotes[0].id).toBe(folder1.id);
|
||||
|
||||
let deletedItems = await BaseItem.deletedItems(syncTargetId());
|
||||
expect(deletedItems.length).toBe(0);
|
||||
@@ -279,7 +293,7 @@ describe('Synchronizer', function() {
|
||||
await switchClient(1);
|
||||
|
||||
context1 = await synchronizer().start({ context: context1 });
|
||||
let items = await allItems();
|
||||
let items = await allNotesFolders();
|
||||
expect(items.length).toBe(2);
|
||||
let deletedItems = await BaseItem.deletedItems(syncTargetId());
|
||||
expect(deletedItems.length).toBe(0);
|
||||
@@ -302,8 +316,8 @@ describe('Synchronizer', function() {
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
let all = await allItems();
|
||||
await localItemsSameAsRemote(all, expect);
|
||||
let all = await allNotesFolders();
|
||||
await localNotesFoldersSameAsRemote(all, expect);
|
||||
}));
|
||||
|
||||
it('should delete local folder', asyncTest(async () => {
|
||||
@@ -320,8 +334,8 @@ describe('Synchronizer', function() {
|
||||
await switchClient(1);
|
||||
|
||||
await synchronizer().start({ context: context1 });
|
||||
let items = await allItems();
|
||||
await localItemsSameAsRemote(items, expect);
|
||||
let items = await allNotesFolders();
|
||||
await localNotesFoldersSameAsRemote(items, expect);
|
||||
}));
|
||||
|
||||
it('should resolve conflict if remote folder has been deleted, but note has been added to folder locally', asyncTest(async () => {
|
||||
@@ -338,7 +352,7 @@ describe('Synchronizer', function() {
|
||||
|
||||
let note = await Note.save({ title: "note1", parent_id: folder1.id });
|
||||
await synchronizer().start();
|
||||
let items = await allItems();
|
||||
let items = await allNotesFolders();
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].title).toBe('note1');
|
||||
expect(items[0].is_conflict).toBe(1);
|
||||
@@ -360,11 +374,11 @@ describe('Synchronizer', function() {
|
||||
await Note.delete(note.id);
|
||||
await synchronizer().start();
|
||||
|
||||
let items = await allItems();
|
||||
let items = await allNotesFolders();
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].title).toBe('folder');
|
||||
|
||||
await localItemsSameAsRemote(items, expect);
|
||||
await localNotesFoldersSameAsRemote(items, expect);
|
||||
}));
|
||||
|
||||
it('should cross delete all folders', asyncTest(async () => {
|
||||
@@ -393,13 +407,13 @@ describe('Synchronizer', function() {
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
let items2 = await allItems();
|
||||
let items2 = await allNotesFolders();
|
||||
|
||||
await switchClient(1);
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
let items1 = await allItems();
|
||||
let items1 = await allNotesFolders();
|
||||
|
||||
expect(items1.length).toBe(0);
|
||||
expect(items1.length).toBe(items2.length);
|
||||
@@ -462,7 +476,7 @@ describe('Synchronizer', function() {
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
let items = await allItems();
|
||||
let items = await allNotesFolders();
|
||||
|
||||
expect(items.length).toBe(1);
|
||||
}));
|
||||
@@ -680,7 +694,7 @@ describe('Synchronizer', function() {
|
||||
let disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
|
||||
expect(disabledItems.length).toBe(0);
|
||||
await Note.save({ id: noteId, title: "un mod", });
|
||||
synchronizer().testingHooks_ = ['rejectedByTarget'];
|
||||
synchronizer().testingHooks_ = ['notesRejectedByTarget'];
|
||||
await synchronizer().start();
|
||||
synchronizer().testingHooks_ = [];
|
||||
await synchronizer().start(); // Another sync to check that this item is now excluded from sync
|
||||
@@ -833,8 +847,8 @@ describe('Synchronizer', function() {
|
||||
await shim.attachFileToNote(note1, __dirname + '/../tests/support/photo.jpg');
|
||||
let resource1 = (await Resource.all())[0];
|
||||
let resourcePath1 = Resource.fullPath(resource1);
|
||||
await synchronizer().start();
|
||||
expect((await fileApi().list()).items.length).toBe(3);
|
||||
await synchronizer().start();
|
||||
expect((await remoteNotesFoldersResources()).length).toBe(3);
|
||||
|
||||
await switchClient(2);
|
||||
|
||||
@@ -901,11 +915,10 @@ describe('Synchronizer', function() {
|
||||
let allResources = await Resource.all();
|
||||
expect(allResources.length).toBe(1);
|
||||
let all = await fileApi().list();
|
||||
expect(all.items.length).toBe(3);
|
||||
expect((await remoteNotesFoldersResources()).length).toBe(3);
|
||||
await Resource.delete(resource1.id);
|
||||
await synchronizer().start();
|
||||
all = await fileApi().list();
|
||||
expect(all.items.length).toBe(2);
|
||||
expect((await remoteNotesFoldersResources()).length).toBe(2);
|
||||
|
||||
await switchClient(1);
|
||||
|
||||
@@ -1036,11 +1049,11 @@ describe('Synchronizer', function() {
|
||||
it('should create remote items with UTF-8 content', asyncTest(async () => {
|
||||
let folder = await Folder.save({ title: "Fahrräder" });
|
||||
await Note.save({ title: "Fahrräder", body: "Fahrräder", parent_id: folder.id });
|
||||
let all = await allItems();
|
||||
let all = await allNotesFolders();
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
await localItemsSameAsRemote(all, expect);
|
||||
await localNotesFoldersSameAsRemote(all, expect);
|
||||
}));
|
||||
|
||||
it("should update remote items but not pull remote changes", asyncTest(async () => {
|
||||
@@ -1058,7 +1071,7 @@ describe('Synchronizer', function() {
|
||||
|
||||
await Note.save({ title: "un UPDATE", id: note.id });
|
||||
await synchronizer().start({ syncSteps: ["update_remote"] });
|
||||
let all = await allItems();
|
||||
let all = await allNotesFolders();
|
||||
expect(all.length).toBe(2);
|
||||
|
||||
await switchClient(2);
|
||||
@@ -1110,4 +1123,133 @@ describe('Synchronizer', function() {
|
||||
expect(tags.length).toBe(2);
|
||||
}));
|
||||
|
||||
it("should not save revisions when updating a note via sync", asyncTest(async () => {
|
||||
// When a note is updated, a revision of the original is created.
|
||||
// Here, on client 1, the note is updated for the first time, however since it is
|
||||
// via sync, we don't create a revision - that revision has already been created on client
|
||||
// 2 and is going to be synced.
|
||||
|
||||
const n1 = await Note.save({ title: 'testing' });
|
||||
await synchronizer().start();
|
||||
|
||||
await switchClient(2);
|
||||
|
||||
await synchronizer().start();
|
||||
await Note.save({ id: n1.id, title: 'mod from client 2' });
|
||||
await revisionService().collectRevisions();
|
||||
const allRevs1 = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(allRevs1.length).toBe(1);
|
||||
await synchronizer().start();
|
||||
|
||||
await switchClient(1);
|
||||
|
||||
await synchronizer().start();
|
||||
const allRevs2 = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(allRevs2.length).toBe(1);
|
||||
expect(allRevs2[0].id).toBe(allRevs1[0].id);
|
||||
}));
|
||||
|
||||
it("should not save revisions when deleting a note via sync", asyncTest(async () => {
|
||||
const n1 = await Note.save({ title: 'testing' });
|
||||
await synchronizer().start();
|
||||
|
||||
await switchClient(2);
|
||||
|
||||
await synchronizer().start();
|
||||
await Note.delete(n1.id);
|
||||
await revisionService().collectRevisions(); // REV 1
|
||||
{
|
||||
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(allRevs.length).toBe(1);
|
||||
}
|
||||
await synchronizer().start();
|
||||
|
||||
await switchClient(1);
|
||||
|
||||
await synchronizer().start(); // The local note gets deleted here, however a new rev is *not* created
|
||||
{
|
||||
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(allRevs.length).toBe(1);
|
||||
}
|
||||
|
||||
const notes = await Note.all();
|
||||
expect(notes.length).toBe(0);
|
||||
}));
|
||||
|
||||
it("should not save revisions when an item_change has been generated as a result of a sync", asyncTest(async () => {
|
||||
// When a note is modified an item_change object is going to be created. This
|
||||
// is used for example to tell the search engine, when note should be indexed. It is
|
||||
// also used by the revision service to tell what note should get a new revision.
|
||||
// When a note is modified via sync, this item_change object is also created. The issue
|
||||
// is that we don't want to create revisions for these particular item_changes, because
|
||||
// such revision has already been created on another client (whatever client initially
|
||||
// modified the note), and that rev is going to be synced.
|
||||
//
|
||||
// So in the end we need to make sure that we don't create these unecessary additional revisions.
|
||||
|
||||
const n1 = await Note.save({ title: 'testing' });
|
||||
await synchronizer().start();
|
||||
|
||||
await switchClient(2);
|
||||
|
||||
await synchronizer().start();
|
||||
await Note.save({ id: n1.id, title: 'mod from client 2' });
|
||||
await revisionService().collectRevisions();
|
||||
await synchronizer().start();
|
||||
|
||||
await switchClient(1);
|
||||
|
||||
await synchronizer().start();
|
||||
|
||||
{
|
||||
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(allRevs.length).toBe(1);
|
||||
}
|
||||
|
||||
await revisionService().collectRevisions();
|
||||
|
||||
{
|
||||
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(allRevs.length).toBe(1);
|
||||
}
|
||||
}));
|
||||
|
||||
it("should handle case when new rev is created on client, then older rev arrives later via sync", asyncTest(async () => {
|
||||
// - C1 creates note 1
|
||||
// - C1 modifies note 1 - REV1 created
|
||||
// - C1 sync
|
||||
// - C2 sync
|
||||
// - C2 receives note 1
|
||||
// - C2 modifies note 1 - REV2 created (but not based on REV1)
|
||||
// - C2 receives REV1
|
||||
//
|
||||
// In that case, we need to make sure that REV1 and REV2 are both valid and can be retrieved.
|
||||
// Even though REV1 was created before REV2, REV2 is *not* based on REV1. This is not ideal
|
||||
// due to unecessary data being saved, but a possible edge case and we simply need to check
|
||||
// all the data is valid.
|
||||
|
||||
const n1 = await Note.save({ title: 'note' });
|
||||
await Note.save({ id: n1.id, title: 'note REV1' });
|
||||
await revisionService().collectRevisions(); // REV1
|
||||
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
|
||||
await synchronizer().start();
|
||||
|
||||
await switchClient(2);
|
||||
|
||||
synchronizer().testingHooks_ = ['skipRevisions'];
|
||||
await synchronizer().start();
|
||||
synchronizer().testingHooks_ = [];
|
||||
|
||||
await Note.save({ id: n1.id, title: 'note REV2' });
|
||||
await revisionService().collectRevisions(); // REV2
|
||||
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
|
||||
await synchronizer().start(); // Sync the rev that had been skipped above with skipRevisions
|
||||
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
||||
expect(revisions.length).toBe(2);
|
||||
|
||||
expect((await revisionService().revisionNote(revisions, 0)).title).toBe('note REV1');
|
||||
expect((await revisionService().revisionNote(revisions, 1)).title).toBe('note REV2');
|
||||
}));
|
||||
|
||||
});
|
||||
|
@@ -8,6 +8,7 @@ const ItemChange = require('lib/models/ItemChange.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Revision = require('lib/models/Revision.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const MasterKey = require('lib/models/MasterKey');
|
||||
@@ -31,12 +32,14 @@ const SyncTargetDropbox = require('lib/SyncTargetDropbox.js');
|
||||
const EncryptionService = require('lib/services/EncryptionService.js');
|
||||
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
|
||||
const ResourceService = require('lib/services/ResourceService.js');
|
||||
const RevisionService = require('lib/services/RevisionService.js');
|
||||
const WebDavApi = require('lib/WebDavApi');
|
||||
const DropboxApi = require('lib/DropboxApi');
|
||||
|
||||
let databases_ = [];
|
||||
let synchronizers_ = [];
|
||||
let encryptionServices_ = [];
|
||||
let revisionServices_ = [];
|
||||
let decryptionWorkers_ = [];
|
||||
let resourceServices_ = [];
|
||||
let fileApi_ = null;
|
||||
@@ -82,6 +85,7 @@ BaseItem.loadClass('Resource', Resource);
|
||||
BaseItem.loadClass('Tag', Tag);
|
||||
BaseItem.loadClass('NoteTag', NoteTag);
|
||||
BaseItem.loadClass('MasterKey', MasterKey);
|
||||
BaseItem.loadClass('Revision', Revision);
|
||||
|
||||
Setting.setConstant('appId', 'net.cozic.joplin-cli');
|
||||
Setting.setConstant('appType', 'cli');
|
||||
@@ -118,6 +122,7 @@ async function switchClient(id) {
|
||||
|
||||
BaseItem.encryptionService_ = encryptionServices_[id];
|
||||
Resource.encryptionService_ = encryptionServices_[id];
|
||||
BaseItem.revisionService_ = revisionServices_[id];
|
||||
|
||||
Setting.setConstant('resourceDir', resourceDir(id));
|
||||
|
||||
@@ -129,21 +134,28 @@ async function clearDatabase(id = null) {
|
||||
|
||||
await ItemChange.waitForAllSaved();
|
||||
|
||||
let queries = [
|
||||
'DELETE FROM notes',
|
||||
'DELETE FROM folders',
|
||||
'DELETE FROM resources',
|
||||
'DELETE FROM tags',
|
||||
'DELETE FROM note_tags',
|
||||
'DELETE FROM master_keys',
|
||||
'DELETE FROM item_changes',
|
||||
'DELETE FROM note_resources',
|
||||
'DELETE FROM settings',
|
||||
'DELETE FROM deleted_items',
|
||||
'DELETE FROM sync_items',
|
||||
'DELETE FROM notes_normalized',
|
||||
const tableNames = [
|
||||
'notes',
|
||||
'folders',
|
||||
'resources',
|
||||
'tags',
|
||||
'note_tags',
|
||||
'master_keys',
|
||||
'item_changes',
|
||||
'note_resources',
|
||||
'settings',
|
||||
'deleted_items',
|
||||
'sync_items',
|
||||
'notes_normalized',
|
||||
'revisions',
|
||||
];
|
||||
|
||||
const queries = [];
|
||||
for (const n of tableNames) {
|
||||
queries.push('DELETE FROM ' + n);
|
||||
queries.push('DELETE FROM sqlite_sequence WHERE name="' + n + '"'); // Reset autoincremented IDs
|
||||
}
|
||||
|
||||
await databases_[id].transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
@@ -168,6 +180,7 @@ async function setupDatabase(id = null) {
|
||||
};
|
||||
|
||||
databases_[id] = new JoplinDatabase(new DatabaseDriverNode());
|
||||
databases_[id].setLogger(logger);
|
||||
await databases_[id].open({ name: filePath });
|
||||
|
||||
BaseModel.db_ = databases_[id];
|
||||
@@ -200,6 +213,7 @@ async function setupDatabaseAndSynchronizer(id = null) {
|
||||
}
|
||||
|
||||
encryptionServices_[id] = new EncryptionService();
|
||||
revisionServices_[id] = new RevisionService();
|
||||
decryptionWorkers_[id] = new DecryptionWorker();
|
||||
decryptionWorkers_[id].setEncryptionService(encryptionServices_[id]);
|
||||
resourceServices_[id] = new ResourceService();
|
||||
@@ -222,6 +236,11 @@ function encryptionService(id = null) {
|
||||
return encryptionServices_[id];
|
||||
}
|
||||
|
||||
function revisionService(id = null) {
|
||||
if (id === null) id = currentClient_;
|
||||
return revisionServices_[id];
|
||||
}
|
||||
|
||||
function decryptionWorker(id = null) {
|
||||
if (id === null) id = currentClient_;
|
||||
return decryptionWorkers_[id];
|
||||
@@ -354,4 +373,4 @@ async function allSyncTargetItemsEncrypted() {
|
||||
return totalCount === encryptedCount;
|
||||
}
|
||||
|
||||
module.exports = { resourceService, allSyncTargetItemsEncrypted, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest };
|
||||
module.exports = { resourceService, allSyncTargetItemsEncrypted, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest };
|
||||
|
@@ -81,7 +81,7 @@ class ElectronAppWrapper {
|
||||
}))
|
||||
|
||||
// Uncomment this to view errors if the application does not start
|
||||
// if (this.env_ === 'dev') this.win_.webContents.openDevTools();
|
||||
if (this.env_ === 'dev') this.win_.webContents.openDevTools();
|
||||
|
||||
this.win_.on('close', (event) => {
|
||||
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)
|
||||
|
@@ -29,6 +29,7 @@ const { bridge } = require('electron').remote.require('./bridge');
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const PluginManager = require('lib/services/PluginManager');
|
||||
const RevisionService = require('lib/services/RevisionService');
|
||||
|
||||
const pluginClasses = [
|
||||
require('./plugins/GotoAnything.min'),
|
||||
@@ -1031,6 +1032,11 @@ class Application extends BaseApplication {
|
||||
|
||||
ExternalEditWatcher.instance().setLogger(reg.logger());
|
||||
ExternalEditWatcher.instance().dispatch = this.store().dispatch;
|
||||
|
||||
RevisionService.instance().runInBackground();
|
||||
|
||||
// Make it available to the console window - useful to call revisionService.collectRevisions()
|
||||
window.revisionService = RevisionService.instance();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -76,6 +76,26 @@ class ConfigScreenComponent extends React.Component {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (section.name === 'sync') {
|
||||
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']);
|
||||
|
||||
if (syncTargetMd.supportsConfigCheck) {
|
||||
const messages = shared.checkSyncConfigMessages(this);
|
||||
const statusStyle = Object.assign({}, theme.textStyle, { marginTop: 10 });
|
||||
const statusComp = !messages.length ? null : (
|
||||
<div style={statusStyle}>
|
||||
{messages[0]}
|
||||
{messages.length >= 1 ? (<p>{messages[1]}</p>) : null}
|
||||
</div>);
|
||||
|
||||
settingComps.push(
|
||||
<div key="check_sync_config_button" style={this.rowStyle_}>
|
||||
<button disabled={this.state.checkSyncConfigResult === 'checking'} style={theme.buttonStyle} onClick={this.checkSyncConfig_}>{_('Check synchronisation configuration')}</button>
|
||||
{ statusComp }
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} style={sectionStyle}>
|
||||
<h2 style={headerStyle}>{Setting.sectionNameToLabel(section.name)}</h2>
|
||||
@@ -265,9 +285,12 @@ class ConfigScreenComponent extends React.Component {
|
||||
updateSettingValue(key, event.target.value);
|
||||
};
|
||||
|
||||
const label = [md.label()];
|
||||
if (md.unitLabel) label.push('(' + md.unitLabel() + ')');
|
||||
|
||||
return (
|
||||
<div key={key} style={rowStyle}>
|
||||
<div style={labelStyle}><label>{md.label()}</label></div>
|
||||
<div style={labelStyle}><label>{label.join(' ')}</label></div>
|
||||
<input type="number" style={controlStyle} value={this.state.settings[key]} onChange={(event) => {onNumChange(event)}} min={md.minimum} max={md.maximum} step={md.step}/>
|
||||
{ descriptionComp }
|
||||
</div>
|
||||
@@ -320,24 +343,6 @@ class ConfigScreenComponent extends React.Component {
|
||||
|
||||
const settingComps = shared.settingsToComponents2(this, 'desktop', settings);
|
||||
|
||||
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']);
|
||||
|
||||
if (syncTargetMd.supportsConfigCheck) {
|
||||
const messages = shared.checkSyncConfigMessages(this);
|
||||
const statusStyle = Object.assign({}, theme.textStyle, { marginTop: 10 });
|
||||
const statusComp = !messages.length ? null : (
|
||||
<div style={statusStyle}>
|
||||
{messages[0]}
|
||||
{messages.length >= 1 ? (<p>{messages[1]}</p>) : null}
|
||||
</div>);
|
||||
|
||||
settingComps.push(
|
||||
<div key="check_sync_config_button" style={this.rowStyle_}>
|
||||
<button disabled={this.state.checkSyncConfigResult === 'checking'} style={buttonStyle} onClick={this.checkSyncConfig_}>{_('Check synchronisation configuration')}</button>
|
||||
{ statusComp }
|
||||
</div>);
|
||||
}
|
||||
|
||||
const buttonBarStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
39
ElectronClient/app/gui/HelpButton.jsx
Normal file
39
ElectronClient/app/gui/HelpButton.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
const React = require('react');
|
||||
const { connect } = require('react-redux');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { themeStyle } = require('../theme.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { bridge } = require('electron').remote.require('./bridge');
|
||||
|
||||
class HelpButtonComponent extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.props.onClick) this.props.onClick();
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
let style = Object.assign({}, this.props.style, {color: theme.color, textDecoration: 'none'});
|
||||
const helpIconStyle = {flex:0, width: 16, height: 16, marginLeft: 10};
|
||||
const extraProps = {};
|
||||
if (this.props.tip) extraProps['data-tip'] = this.props.tip;
|
||||
return <a href="#" style={style} onClick={this.onClick} {...extraProps}><i style={helpIconStyle} className={"fa fa-question-circle"}></i></a>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
};
|
||||
|
||||
const HelpButton = connect(mapStateToProps)(HelpButtonComponent);
|
||||
|
||||
module.exports = HelpButton;
|
@@ -227,6 +227,7 @@ class MainScreenComponent extends React.Component {
|
||||
notePropertiesDialogOptions: {
|
||||
noteId: command.noteId,
|
||||
visible: true,
|
||||
onRevisionLinkClick: command.onRevisionLinkClick,
|
||||
},
|
||||
});
|
||||
} else if (command.name === 'toggleVisiblePanes') {
|
||||
@@ -474,6 +475,7 @@ class MainScreenComponent extends React.Component {
|
||||
theme={this.props.theme}
|
||||
noteId={notePropertiesDialogOptions.noteId}
|
||||
onClose={this.notePropertiesDialog_close}
|
||||
onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick}
|
||||
/> }
|
||||
|
||||
<PromptDialog
|
||||
|
@@ -17,6 +17,7 @@ class NotePropertiesDialog extends React.Component {
|
||||
this.okButton_click = this.okButton_click.bind(this);
|
||||
this.cancelButton_click = this.cancelButton_click.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.revisionsLink_click = this.revisionsLink_click.bind(this);
|
||||
this.okButton = React.createRef();
|
||||
|
||||
this.state = {
|
||||
@@ -31,6 +32,7 @@ class NotePropertiesDialog extends React.Component {
|
||||
user_updated_time: _('Updated'),
|
||||
location: _('Location'),
|
||||
source_url: _('URL'),
|
||||
revisionsLink: _('Note History'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +81,7 @@ class NotePropertiesDialog extends React.Component {
|
||||
formNote.location = note.latitude + ', ' + note.longitude;
|
||||
}
|
||||
|
||||
formNote.revisionsLink = note.id;
|
||||
formNote.id = note.id;
|
||||
|
||||
return formNote;
|
||||
@@ -102,26 +105,6 @@ class NotePropertiesDialog extends React.Component {
|
||||
this.styles_ = {};
|
||||
this.styleKey_ = styleKey;
|
||||
|
||||
// this.styles_.modalLayer = {
|
||||
// zIndex: 9999,
|
||||
// display: 'flex',
|
||||
// position: 'absolute',
|
||||
// top: 0,
|
||||
// left: 0,
|
||||
// width: '100%',
|
||||
// height: '100%',
|
||||
// backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
// alignItems: 'flex-start',
|
||||
// justifyContent: 'center',
|
||||
// };
|
||||
|
||||
// this.styles_.dialogBox = {
|
||||
// backgroundColor: theme.backgroundColor,
|
||||
// padding: 16,
|
||||
// boxShadow: '6px 6px 20px rgba(0,0,0,0.5)',
|
||||
// marginTop: 20,
|
||||
// }
|
||||
|
||||
this.styles_.controlBox = {
|
||||
marginBottom: '1em',
|
||||
color: 'black', //This will apply for the calendar
|
||||
@@ -153,8 +136,6 @@ class NotePropertiesDialog extends React.Component {
|
||||
borderColor: theme.dividerColor,
|
||||
};
|
||||
|
||||
// this.styles_.dialogTitle = Object.assign({}, theme.h1Style, { marginBottom: '1.2em' });
|
||||
|
||||
return this.styles_;
|
||||
}
|
||||
|
||||
@@ -181,6 +162,11 @@ class NotePropertiesDialog extends React.Component {
|
||||
this.closeDialog(false);
|
||||
}
|
||||
|
||||
revisionsLink_click() {
|
||||
this.closeDialog(false);
|
||||
if (this.props.onRevisionLinkClick) this.props.onRevisionLinkClick();
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
if (event.keyCode === 13) {
|
||||
this.closeDialog(true);
|
||||
@@ -300,11 +286,13 @@ class NotePropertiesDialog extends React.Component {
|
||||
url = Note.geoLocationUrlFromLatLong(ll.latitude, ll.longitude);
|
||||
}
|
||||
controlComp = <a href="#" onClick={() => bridge().openExternal(url)} style={theme.urlStyle}>{displayedValue}</a>
|
||||
} else if (key === 'revisionsLink') {
|
||||
controlComp = <a href="#" onClick={this.revisionsLink_click} style={theme.urlStyle}>{_('Previous versions of this note')}</a>
|
||||
} else {
|
||||
controlComp = <div style={Object.assign({}, theme.textStyle, {display: 'inline-block'})}>{displayedValue}</div>
|
||||
}
|
||||
|
||||
if (key !== 'id') {
|
||||
if (key !== 'id' && key !== 'revisionsLink') {
|
||||
editCompHandler = () => {this.editPropertyButtonClick(key, value)};
|
||||
editCompIcon = 'fa-edit';
|
||||
}
|
||||
|
175
ElectronClient/app/gui/NoteRevisionViewer.jsx
Normal file
175
ElectronClient/app/gui/NoteRevisionViewer.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
const React = require('react');
|
||||
const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('../theme.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const NoteTextViewer = require('./NoteTextViewer.min');
|
||||
const HelpButton = require('./HelpButton.min');
|
||||
const BaseModel = require('lib/BaseModel');
|
||||
const Revision = require('lib/models/Revision');
|
||||
const Setting = require('lib/models/Setting');
|
||||
const RevisionService = require('lib/services/RevisionService');
|
||||
const shared = require('lib/components/shared/note-screen-shared.js');
|
||||
const MdToHtml = require('lib/MdToHtml');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const ReactTooltip = require('react-tooltip');
|
||||
const { substrWithEllipsis } = require('lib/string-utils');
|
||||
|
||||
class NoteRevisionViewerComponent extends React.PureComponent {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
revisions: [],
|
||||
currentRevId: '',
|
||||
note: null,
|
||||
restoring: false,
|
||||
};
|
||||
|
||||
this.viewerRef_ = React.createRef();
|
||||
|
||||
this.viewer_domReady = this.viewer_domReady.bind(this);
|
||||
this.revisionList_onChange = this.revisionList_onChange.bind(this);
|
||||
this.importButton_onClick = this.importButton_onClick.bind(this);
|
||||
this.backButton_click = this.backButton_click.bind(this);
|
||||
}
|
||||
|
||||
style() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
let style = {
|
||||
root: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
titleInput: Object.assign({}, theme.inputStyle, { flex: 1 }),
|
||||
revisionList: Object.assign({}, theme.dropdownList, { marginLeft: 10, flex: 0.5 }),
|
||||
};
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
async viewer_domReady() {
|
||||
// this.viewerRef_.current.wrappedInstance.openDevTools();
|
||||
|
||||
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, this.props.noteId);
|
||||
|
||||
this.setState({
|
||||
revisions: revisions,
|
||||
currentRevId: revisions.length ? revisions[revisions.length - 1].id : '',
|
||||
}, () => {
|
||||
this.reloadNote();
|
||||
});
|
||||
}
|
||||
|
||||
async importButton_onClick() {
|
||||
if (!this.state.note) return;
|
||||
this.setState({ restoring: true });
|
||||
await RevisionService.instance().importRevisionNote(this.state.note);
|
||||
this.setState({ restoring: false });
|
||||
alert(_('The note "%s" has been successfully restored to the notebook "%s".', substrWithEllipsis(this.state.note.title, 0, 32), RevisionService.instance().restoreFolderTitle()));
|
||||
}
|
||||
|
||||
backButton_click() {
|
||||
if (this.props.onBack) this.props.onBack();
|
||||
}
|
||||
|
||||
revisionList_onChange(event) {
|
||||
const value = event.target.value;
|
||||
|
||||
if (!value) {
|
||||
if (this.props.onBack) this.props.onBack();
|
||||
} else {
|
||||
this.setState({
|
||||
currentRevId: value,
|
||||
}, () => {
|
||||
this.reloadNote();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reloadNote() {
|
||||
let noteBody = '';
|
||||
if (!this.state.revisions.length || !this.state.currentRevId) {
|
||||
noteBody = _('This note has no history');
|
||||
this.setState({ note: null });
|
||||
} else {
|
||||
const revIndex = BaseModel.modelIndexById(this.state.revisions, this.state.currentRevId);
|
||||
const note = await RevisionService.instance().revisionNote(this.state.revisions, revIndex);
|
||||
if (!note) return;
|
||||
noteBody = note.body;
|
||||
this.setState({ note: note });
|
||||
}
|
||||
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
const mdToHtml = new MdToHtml({
|
||||
resourceBaseUrl: 'file://' + Setting.value('resourceDir') + '/',
|
||||
});
|
||||
|
||||
const result = mdToHtml.render(noteBody, theme, {
|
||||
codeTheme: theme.codeThemeCss,
|
||||
userCss: this.props.customCss ? this.props.customCss : '',
|
||||
resources: await shared.attachedResources(noteBody),
|
||||
});
|
||||
|
||||
this.viewerRef_.current.wrappedInstance.send('setHtml', result.html, { cssFiles: result.cssFiles });
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
const style = this.style();
|
||||
|
||||
const revisionListItems = [];
|
||||
const revs = this.state.revisions.slice().reverse();
|
||||
for (let i = 0; i < revs.length; i++) {
|
||||
const rev = revs[i];
|
||||
revisionListItems.push(<option
|
||||
key={rev.id}
|
||||
value={rev.id}
|
||||
>{time.formatMsToLocal(rev.item_updated_time)}</option>);
|
||||
}
|
||||
|
||||
const restoreButtonTitle = _('Restore');
|
||||
const helpMessage = _('Click "%s" to restore the note. It will be copied in the notebook named "%s". The current version of the note will not be replaced or modified.', restoreButtonTitle, RevisionService.instance().restoreFolderTitle());
|
||||
|
||||
const titleInput = (
|
||||
<div style={{display:'flex', flexDirection: 'row', alignItems:'center', marginBottom: 10, borderWidth: 1, borderBottomStyle: 'solid', borderColor: theme.dividerColor, paddingBottom:10}}>
|
||||
<button onClick={this.backButton_click} style={Object.assign({}, theme.buttonStyle, { marginRight: 10, height: theme.inputStyle.height })}>{'⬅ ' + _('Back')}</button>
|
||||
<input readOnly type="text" style={style.titleInput} value={this.state.note ? this.state.note.title : ''}/>
|
||||
<select disabled={!this.state.revisions.length} value={this.state.currentRevId} style={style.revisionList} onChange={this.revisionList_onChange}>
|
||||
{revisionListItems}
|
||||
</select>
|
||||
<button disabled={!this.state.revisions.length || this.state.restoring} onClick={this.importButton_onClick} style={Object.assign({}, theme.buttonStyle, { marginLeft: 10, height: theme.inputStyle.height })}>{restoreButtonTitle}</button>
|
||||
<HelpButton tip={helpMessage} id="noteRevisionHelpButton" onClick={this.helpButton_onClick}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewer = <NoteTextViewer
|
||||
viewerStyle={{display:'flex', flex:1}}
|
||||
ref={this.viewerRef_}
|
||||
onDomReady={this.viewer_domReady}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div style={style.root}>
|
||||
{titleInput}
|
||||
{viewer}
|
||||
<ReactTooltip place="bottom" delayShow={300} className="help-tooltip"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
};
|
||||
|
||||
const NoteRevisionViewer = connect(mapStateToProps)(NoteRevisionViewerComponent);
|
||||
|
||||
module.exports = NoteRevisionViewer;
|
@@ -38,6 +38,7 @@ const { clipboard } = require('electron');
|
||||
const SearchEngine = require('lib/services/SearchEngine');
|
||||
const ModelCache = require('lib/services/ModelCache');
|
||||
const NoteTextViewer = require('./NoteTextViewer.min');
|
||||
const NoteRevisionViewer = require('./NoteRevisionViewer.min');
|
||||
|
||||
require('brace/mode/markdown');
|
||||
// https://ace.c9.io/build/kitchen-sink.html
|
||||
@@ -70,6 +71,7 @@ class NoteTextComponent extends React.Component {
|
||||
editorScrollTop: 0,
|
||||
newNote: null,
|
||||
noteTags: [],
|
||||
showRevisions: false,
|
||||
|
||||
// If the current note was just created, and the title has never been
|
||||
// changed by the user, this variable contains that note ID. Used
|
||||
@@ -268,6 +270,7 @@ class NoteTextComponent extends React.Component {
|
||||
this.titleField_keyDown = this.titleField_keyDown.bind(this);
|
||||
this.webview_ipcMessage = this.webview_ipcMessage.bind(this);
|
||||
this.webview_domReady = this.webview_domReady.bind(this);
|
||||
this.noteRevisionViewer_onBack = this.noteRevisionViewer_onBack.bind(this);
|
||||
}
|
||||
|
||||
// Note:
|
||||
@@ -501,7 +504,7 @@ class NoteTextComponent extends React.Component {
|
||||
// 2. It resets the undo manager - fixes https://github.com/laurent22/joplin/issues/355
|
||||
// Note: calling undoManager.reset() doesn't work
|
||||
try {
|
||||
this.editor_.editor.getSession().setValue(note ? note.body : '');
|
||||
this.editor_.editor.getSession().setValue(note && note.body? note.body : '');
|
||||
} catch (error) {
|
||||
if (error.message === "Cannot read property 'match' of undefined") {
|
||||
// The internals of Ace Editor throws an exception when creating a new note,
|
||||
@@ -530,7 +533,8 @@ class NoteTextComponent extends React.Component {
|
||||
webviewReady: webviewReady,
|
||||
folder: parentFolder,
|
||||
lastKeys: [],
|
||||
noteTags: noteTags
|
||||
noteTags: noteTags,
|
||||
showRevisions: false,
|
||||
};
|
||||
|
||||
if (!note) {
|
||||
@@ -619,6 +623,13 @@ class NoteTextComponent extends React.Component {
|
||||
return shared.refreshNoteMetadata(this, force);
|
||||
}
|
||||
|
||||
async noteRevisionViewer_onBack() {
|
||||
this.setState({ showRevisions: false });
|
||||
|
||||
this.lastSetHtml_ = '';
|
||||
this.scheduleReloadNote(this.props);
|
||||
}
|
||||
|
||||
title_changeText(event) {
|
||||
shared.noteComponent_change(this, 'title', event.target.value);
|
||||
this.setState({ newAndNoTitleChangeNoteId: null });
|
||||
@@ -1529,6 +1540,7 @@ class NoteTextComponent extends React.Component {
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'commandNoteProperties',
|
||||
noteId: n.id,
|
||||
onRevisionLinkClick: () => { this.setState({ showRevisions: true}) },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1610,6 +1622,18 @@ class NoteTextComponent extends React.Component {
|
||||
|
||||
const innerWidth = rootStyle.width - rootStyle.paddingLeft - rootStyle.paddingRight - borderWidth;
|
||||
|
||||
if (this.state.showRevisions && note && note.id) {
|
||||
rootStyle.paddingRight = rootStyle.paddingLeft;
|
||||
rootStyle.paddingTop = rootStyle.paddingLeft;
|
||||
rootStyle.paddingBottom = rootStyle.paddingLeft;
|
||||
rootStyle.display = 'inline-flex';
|
||||
return (
|
||||
<div style={rootStyle}>
|
||||
<NoteRevisionViewer noteId={note.id} customCss={this.props.customCss} onBack={this.noteRevisionViewer_onBack}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.selectedNoteIds.length > 1) {
|
||||
return this.renderMultiNotes(rootStyle);
|
||||
} else if (!note || !!note.encryption_applied) { //|| (note && !this.props.newNote && this.props.noteId && note.id !== this.props.noteId)) { // note.id !== props.noteId is when the note has not been loaded yet, and the previous one is still in the state
|
||||
@@ -1710,7 +1734,7 @@ class NoteTextComponent extends React.Component {
|
||||
viewerStyle.borderLeft = 'none';
|
||||
}
|
||||
|
||||
if (this.state.webviewReady) {
|
||||
if (this.state.webviewReady && this.webviewRef_.current) {
|
||||
let html = this.state.bodyHtml;
|
||||
|
||||
const htmlHasChanged = this.lastSetHtml_ !== html;
|
||||
|
@@ -18,11 +18,11 @@ class NoteTextViewerComponent extends React.Component {
|
||||
}
|
||||
|
||||
webview_domReady(event) {
|
||||
this.props.onDomReady(event);
|
||||
if (this.props.onDomReady) this.props.onDomReady(event);
|
||||
}
|
||||
|
||||
webview_ipcMessage(event) {
|
||||
this.props.onIpcMessage(event);
|
||||
if (this.props.onIpcMessage) this.props.onIpcMessage(event);
|
||||
}
|
||||
|
||||
initWebview() {
|
||||
@@ -67,13 +67,21 @@ class NoteTextViewerComponent extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
tryInit() {
|
||||
if (!this.initialized_ && this.webviewRef_.current) {
|
||||
this.initWebview();
|
||||
this.initialized_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.tryInit();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.tryInit();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroyWebview();
|
||||
}
|
||||
|
@@ -221,7 +221,7 @@ class SideBarComponent extends React.Component {
|
||||
}
|
||||
}
|
||||
} else if (command.name === 'synchronize') {
|
||||
this.sync_click();
|
||||
if (!this.props.syncStarted) this.sync_click();
|
||||
} else {
|
||||
commandProcessed = false;
|
||||
}
|
||||
|
@@ -21,6 +21,7 @@ const Tag = require('lib/models/Tag.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const MasterKey = require('lib/models/MasterKey');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const Revision = require('lib/models/Revision.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { FsDriverNode } = require('lib/fs-driver-node.js');
|
||||
const { shimInit } = require('lib/shim-init-node.js');
|
||||
@@ -42,6 +43,7 @@ BaseItem.loadClass('Resource', Resource);
|
||||
BaseItem.loadClass('Tag', Tag);
|
||||
BaseItem.loadClass('NoteTag', NoteTag);
|
||||
BaseItem.loadClass('MasterKey', MasterKey);
|
||||
BaseItem.loadClass('Revision', Revision);
|
||||
|
||||
Setting.setConstant('appId', 'net.cozic.joplin-desktop');
|
||||
Setting.setConstant('appType', 'desktop');
|
||||
|
266
ElectronClient/app/package-lock.json
generated
266
ElectronClient/app/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Joplin",
|
||||
"version": "1.0.145",
|
||||
"version": "1.0.148",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -23,9 +23,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"7zip-bin-win": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin-win/-/7zip-bin-win-2.1.1.tgz",
|
||||
"integrity": "sha512-6VGEW7PXGroTsoI2QW3b0ea95HJmbVBHvfANKLLMzSzFA1zKqVX5ybNuhmeGpf6vA0x8FJTt6twpprDANsY5WQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin-win/-/7zip-bin-win-2.2.0.tgz",
|
||||
"integrity": "sha512-uPHXapEmUtlUKTBx4asWMlxtFUWXzEY0KVEgU7QKhgO2LJzzM3kYxM6yOyUZTtYE6mhK4dDn3FDut9SCQWHzgg==",
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
@@ -1214,6 +1214,11 @@
|
||||
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
|
||||
"dev": true
|
||||
},
|
||||
"classnames": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
|
||||
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
|
||||
},
|
||||
"cli-boxes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz",
|
||||
@@ -2357,25 +2362,29 @@
|
||||
"dependencies": {
|
||||
"abbrev": {
|
||||
"version": "1.1.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"are-we-there-yet": {
|
||||
"version": "1.1.4",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2385,13 +2394,15 @@
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2401,37 +2412,43 @@
|
||||
},
|
||||
"chownr": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"debug": {
|
||||
"version": "2.6.9",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2440,25 +2457,29 @@
|
||||
},
|
||||
"deep-extend": {
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"delegates": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"fs-minipass": {
|
||||
"version": "1.2.5",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2467,13 +2488,15 @@
|
||||
},
|
||||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"gauge": {
|
||||
"version": "2.7.4",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2489,7 +2512,8 @@
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2503,13 +2527,15 @@
|
||||
},
|
||||
"has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"iconv-lite": {
|
||||
"version": "0.4.21",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2518,7 +2544,8 @@
|
||||
},
|
||||
"ignore-walk": {
|
||||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2527,7 +2554,8 @@
|
||||
},
|
||||
"inflight": {
|
||||
"version": "1.0.6",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2537,19 +2565,22 @@
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2558,13 +2589,15 @@
|
||||
},
|
||||
"isarray": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2573,13 +2606,15 @@
|
||||
},
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.2.4",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2589,7 +2624,8 @@
|
||||
},
|
||||
"minizlib": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2598,7 +2634,8 @@
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2607,13 +2644,15 @@
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"needle": {
|
||||
"version": "2.2.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2624,7 +2663,8 @@
|
||||
},
|
||||
"node-pre-gyp": {
|
||||
"version": "0.10.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2642,7 +2682,8 @@
|
||||
},
|
||||
"nopt": {
|
||||
"version": "4.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2652,13 +2693,15 @@
|
||||
},
|
||||
"npm-bundled": {
|
||||
"version": "1.0.3",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"npm-packlist": {
|
||||
"version": "1.1.10",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2668,7 +2711,8 @@
|
||||
},
|
||||
"npmlog": {
|
||||
"version": "4.1.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2680,19 +2724,22 @@
|
||||
},
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"once": {
|
||||
"version": "1.4.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2701,19 +2748,22 @@
|
||||
},
|
||||
"os-homedir": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"os-tmpdir": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"osenv": {
|
||||
"version": "0.1.5",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2723,19 +2773,22 @@
|
||||
},
|
||||
"path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"process-nextick-args": {
|
||||
"version": "2.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"rc": {
|
||||
"version": "1.2.7",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2747,7 +2800,8 @@
|
||||
"dependencies": {
|
||||
"minimist": {
|
||||
"version": "1.2.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
@@ -2755,7 +2809,8 @@
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.6",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2770,7 +2825,8 @@
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.6.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2779,43 +2835,50 @@
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.1.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"sax": {
|
||||
"version": "1.2.4",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.5.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"signal-exit": {
|
||||
"version": "3.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"string-width": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2826,7 +2889,8 @@
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2835,7 +2899,8 @@
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2844,13 +2909,15 @@
|
||||
},
|
||||
"strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"tar": {
|
||||
"version": "4.4.1",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2865,13 +2932,15 @@
|
||||
},
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"wide-align": {
|
||||
"version": "1.1.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
@@ -2880,13 +2949,15 @@
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"bundled": true,
|
||||
"resolved": false,
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
@@ -4899,14 +4970,36 @@
|
||||
}
|
||||
},
|
||||
"react": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-16.4.0.tgz",
|
||||
"integrity": "sha512-K0UrkLXSAekf5nJu89obKUM7o2vc6MMN9LYoKnCa+c+8MJRAT120xzPLENcWSRc7GYKIg0LlgJRDorrufdglQQ==",
|
||||
"version": "16.8.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-16.8.1.tgz",
|
||||
"integrity": "sha512-wLw5CFGPdo7p/AgteFz7GblI2JPOos0+biSoxf1FPsGxWQZdN/pj6oToJs1crn61DL3Ln7mN86uZ4j74p31ELQ==",
|
||||
"requires": {
|
||||
"fbjs": "^0.8.16",
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.0"
|
||||
"prop-types": "^15.6.2",
|
||||
"scheduler": "^0.13.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"prop-types": {
|
||||
"version": "15.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
|
||||
"integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.8.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"requires": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-ace": {
|
||||
@@ -4950,6 +5043,11 @@
|
||||
"prop-types": "^15.6.0"
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "16.8.6",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
|
||||
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA=="
|
||||
},
|
||||
"react-onclickoutside": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz",
|
||||
@@ -4975,6 +5073,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-tooltip": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-3.10.0.tgz",
|
||||
"integrity": "sha512-GGdxJvM1zSFztkTP7gCQbLTstWr1OOoMpJ5WZUGhimj0nhRY+MPz+92MpEnKmj0cftJ9Pd/M6FfSl0sfzmZWkg==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.5",
|
||||
"prop-types": "^15.6.0"
|
||||
}
|
||||
},
|
||||
"read-chunk": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-2.1.0.tgz",
|
||||
@@ -5303,6 +5410,15 @@
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"scheduler": {
|
||||
"version": "0.13.6",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz",
|
||||
"integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Joplin",
|
||||
"version": "1.0.145",
|
||||
"version": "1.0.148",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
@@ -88,6 +88,7 @@
|
||||
"chokidar": "^3.0.0",
|
||||
"compare-versions": "^3.2.1",
|
||||
"diacritics": "^1.3.0",
|
||||
"diff-match-patch": "^1.0.4",
|
||||
"electron-context-menu": "^0.9.1",
|
||||
"electron-is-dev": "^0.3.0",
|
||||
"electron-window-state": "^4.1.1",
|
||||
@@ -132,6 +133,7 @@
|
||||
"react-datetime": "^2.14.0",
|
||||
"react-dom": "^16.4.0",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-tooltip": "^3.10.0",
|
||||
"read-chunk": "^2.1.0",
|
||||
"readability-node": "^0.1.0",
|
||||
"redux": "^3.7.2",
|
||||
|
@@ -6,6 +6,7 @@ const SearchEngine = require('lib/services/SearchEngine');
|
||||
const BaseModel = require('lib/BaseModel');
|
||||
const Tag = require('lib/models/Tag');
|
||||
const { ItemList } = require('../gui/ItemList.min');
|
||||
const HelpButton = require('../gui/HelpButton.min');
|
||||
const { substrWithEllipsis, surroundKeywords } = require('lib/string-utils.js');
|
||||
|
||||
const PLUGIN_NAME = 'gotoAnything';
|
||||
@@ -61,8 +62,6 @@ class Dialog extends React.PureComponent {
|
||||
row: {overflow: 'hidden', height:itemHeight, display: 'flex', justifyContent: 'center', flexDirection: 'column', paddingLeft: 10, paddingRight: 10},
|
||||
help: Object.assign({}, theme.textStyle, { marginBottom: 10 }),
|
||||
inputHelpWrapper: {display: 'flex', flexDirection: 'row', alignItems: 'center'},
|
||||
helpIcon: {flex:0, width: 16, height: 16, marginLeft: 10},
|
||||
helpButton: {color: theme.color, textDecoration: 'none'},
|
||||
};
|
||||
|
||||
const rowTextStyle = {
|
||||
@@ -321,7 +320,7 @@ class Dialog extends React.PureComponent {
|
||||
{helpComp}
|
||||
<div style={style.inputHelpWrapper}>
|
||||
<input autoFocus type="text" style={style.input} ref={this.inputRef} value={this.state.query} onChange={this.input_onChange} onKeyDown={this.input_onKeyDown}/>
|
||||
<a href="#" style={style.helpButton} onClick={this.helpButton_onClick}><i style={style.helpIcon} className={"fa fa-question-circle"}></i></a>
|
||||
<HelpButton onClick={this.helpButton_onClick}/>
|
||||
</div>
|
||||
{this.renderList()}
|
||||
</div>
|
||||
|
@@ -113,3 +113,12 @@ table td, table th {
|
||||
.note-property-box .rdt {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.help-tooltip {
|
||||
font-family: sans-serif;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
@@ -34,6 +34,9 @@ globalStyle.icon = {
|
||||
|
||||
globalStyle.lineInput = {
|
||||
fontFamily: globalStyle.fontFamily,
|
||||
maxHeight: 22,
|
||||
height: 22,
|
||||
paddingLeft: 5,
|
||||
};
|
||||
|
||||
globalStyle.headerStyle = {
|
||||
@@ -43,6 +46,7 @@ globalStyle.headerStyle = {
|
||||
globalStyle.inputStyle = {
|
||||
border: '1px solid',
|
||||
height: 24,
|
||||
maxHeight: 24,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
boxSizing: 'border-box',
|
||||
@@ -54,13 +58,14 @@ globalStyle.containerStyle = {
|
||||
};
|
||||
|
||||
globalStyle.buttonStyle = {
|
||||
marginRight: 10,
|
||||
// marginRight: 10,
|
||||
border: '1px solid',
|
||||
minHeight: 30,
|
||||
minHeight: 26,
|
||||
minWidth: 80,
|
||||
maxWidth: 160,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
boxShadow: '0px 1px 1px rgba(0,0,0,0.3)',
|
||||
};
|
||||
|
||||
const lightStyle = {
|
||||
@@ -226,6 +231,8 @@ function addExtraStyles(style) {
|
||||
|
||||
style.dialogTitle = Object.assign({}, style.h1Style, { marginBottom: '1.2em' });
|
||||
|
||||
style.dropdownList = Object.assign({}, style.inputStyle);
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
|
@@ -28,7 +28,7 @@ Linux | <a href='https://github.com/laurent22/joplin/releases/download/
|
||||
|
||||
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.244/joplin-v1.0.244.apk)
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://joplinapp.org/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.246/joplin-v1.0.246.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
|
||||
@@ -299,7 +299,7 @@ It is generally recommended to enter the notes as Markdown as it makes the notes
|
||||
|
||||
## 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.
|
||||
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. Joplin ***must*** be restarted for the new css to be applied, please ensure that Joplin is not closing to the tray, but is actually exiting. 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
|
||||
|
||||
|
@@ -90,8 +90,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097480
|
||||
versionName "1.0.244"
|
||||
versionCode 2097482
|
||||
versionName "1.0.246"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86"
|
||||
}
|
||||
|
@@ -35,6 +35,7 @@ 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');
|
||||
@@ -577,6 +578,8 @@ class BaseApplication {
|
||||
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();
|
||||
|
@@ -539,6 +539,7 @@ BaseModel.typeEnum_ = [
|
||||
['TYPE_ITEM_CHANGE', 10],
|
||||
['TYPE_NOTE_RESOURCE', 11],
|
||||
['TYPE_RESOURCE_LOCAL_STATE', 12],
|
||||
['TYPE_REVISION', 13],
|
||||
];
|
||||
|
||||
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
|
||||
|
@@ -10,7 +10,7 @@ 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');
|
||||
import VersionInfo from 'react-native-version-info';
|
||||
const VersionInfo = require('react-native-version-info').default;
|
||||
|
||||
class ConfigScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -163,10 +163,14 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
</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>
|
||||
<Slider key="control" style={this.styles().settingControl} value={value} onValueChange={(value) => updateSettingValue(key, value)} />
|
||||
<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) {
|
||||
|
@@ -263,7 +263,7 @@ class JoplinDatabase extends Database {
|
||||
// must be set in the synchronizer too.
|
||||
|
||||
// Note: v16 and v17 don't do anything. They were used to debug an issue.
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
|
||||
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
||||
|
||||
@@ -515,6 +515,35 @@ class JoplinDatabase extends Database {
|
||||
END;`);
|
||||
}
|
||||
|
||||
if (targetVersion == 19) {
|
||||
const newTableSql = `
|
||||
CREATE TABLE revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT NOT NULL DEFAULT "",
|
||||
item_type INT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_updated_time INT NOT NULL,
|
||||
title_diff TEXT NOT NULL DEFAULT "",
|
||||
body_diff TEXT NOT NULL DEFAULT "",
|
||||
metadata_diff TEXT NOT NULL DEFAULT "",
|
||||
encryption_cipher_text TEXT NOT NULL DEFAULT "",
|
||||
encryption_applied INT NOT NULL DEFAULT 0,
|
||||
updated_time INT NOT NULL,
|
||||
created_time INT NOT NULL
|
||||
);
|
||||
`;
|
||||
queries.push(this.sqlStringToLines(newTableSql)[0]);
|
||||
|
||||
queries.push('CREATE INDEX revisions_parent_id ON revisions (parent_id)');
|
||||
queries.push('CREATE INDEX revisions_item_type ON revisions (item_type)');
|
||||
queries.push('CREATE INDEX revisions_item_id ON revisions (item_id)');
|
||||
queries.push('CREATE INDEX revisions_item_updated_time ON revisions (item_updated_time)');
|
||||
queries.push('CREATE INDEX revisions_updated_time ON revisions (updated_time)');
|
||||
|
||||
queries.push('ALTER TABLE item_changes ADD COLUMN source INT NOT NULL DEFAULT 1');
|
||||
queries.push('ALTER TABLE item_changes ADD COLUMN before_change_item TEXT NOT NULL DEFAULT ""');
|
||||
}
|
||||
|
||||
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
|
||||
|
||||
try {
|
||||
|
@@ -160,6 +160,7 @@ class BaseItem extends BaseModel {
|
||||
}
|
||||
|
||||
static async batchDelete(ids, options = null) {
|
||||
if (!options) options = {};
|
||||
let trackDeleted = true;
|
||||
if (options && options.trackDeleted !== null && options.trackDeleted !== undefined) trackDeleted = options.trackDeleted;
|
||||
|
||||
@@ -219,6 +220,9 @@ class BaseItem extends BaseModel {
|
||||
if (['created_time', 'updated_time', 'sync_time', 'user_updated_time', 'user_created_time'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return '';
|
||||
propValue = moment.unix(propValue / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
|
||||
} else if (['title_diff', 'body_diff'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return '';
|
||||
propValue = JSON.stringify(propValue);
|
||||
} else if (propValue === null || propValue === undefined) {
|
||||
propValue = '';
|
||||
}
|
||||
@@ -234,6 +238,9 @@ class BaseItem extends BaseModel {
|
||||
if (['created_time', 'updated_time', 'user_created_time', 'user_updated_time'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return 0;
|
||||
propValue = moment(propValue, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x');
|
||||
} else if (['title_diff', 'body_diff'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return '';
|
||||
propValue = JSON.parse(propValue);
|
||||
} else {
|
||||
propValue = Database.formatValue(ItemClass.fieldType(propName), propValue);
|
||||
}
|
||||
@@ -279,7 +286,7 @@ class BaseItem extends BaseModel {
|
||||
|
||||
let temp = [];
|
||||
|
||||
if (output.title) temp.push(output.title);
|
||||
if (typeof output.title === "string") temp.push(output.title);
|
||||
if (output.body) temp.push(output.body);
|
||||
if (output.props.length) temp.push(output.props.join("\n"));
|
||||
|
||||
@@ -291,19 +298,16 @@ class BaseItem extends BaseModel {
|
||||
return this.encryptionService_;
|
||||
}
|
||||
|
||||
static revisionService() {
|
||||
if (!this.revisionService_) throw new Error('BaseItem.revisionService_ is not set!!');
|
||||
return this.revisionService_;
|
||||
}
|
||||
|
||||
static async serializeForSync(item) {
|
||||
const ItemClass = this.itemClass(item);
|
||||
let shownKeys = ItemClass.fieldNames();
|
||||
shownKeys.push('type_');
|
||||
|
||||
// if (ItemClass.syncExcludedKeys) {
|
||||
// const keys = ItemClass.syncExcludedKeys();
|
||||
// for (let i = 0; i < keys.length; i++) {
|
||||
// const idx = shownKeys.indexOf(keys[i]);
|
||||
// shownKeys.splice(idx, 1);
|
||||
// }
|
||||
// }
|
||||
|
||||
const serialized = await ItemClass.serialize(item, shownKeys);
|
||||
|
||||
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported()) {
|
||||
@@ -372,7 +376,7 @@ class BaseItem extends BaseModel {
|
||||
body.splice(0, 0, line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!output.type_) throw new Error('Missing required property: type_: ' + content);
|
||||
output.type_ = Number(output.type_);
|
||||
|
||||
@@ -597,7 +601,7 @@ class BaseItem extends BaseModel {
|
||||
static updateSyncTimeQueries(syncTarget, item, syncTime, syncDisabled = false, syncDisabledReason = '') {
|
||||
const itemType = item.type_;
|
||||
const itemId = item.id;
|
||||
if (!itemType || !itemId || syncTime === undefined) throw new Error('Invalid parameters in updateSyncTimeQueries()');
|
||||
if (!itemType || !itemId || syncTime === undefined) throw new Error(sprintf('Invalid parameters in updateSyncTimeQueries(): %d, %s, %d', syncTarget, JSON.stringify(item), syncTime));
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -644,7 +648,7 @@ class BaseItem extends BaseModel {
|
||||
|
||||
static displayTitle(item) {
|
||||
if (!item) return '';
|
||||
return !!item.encryption_applied ? '🔑 ' + _('Encrypted') : item.title + '';
|
||||
return !!item.encryption_applied ? '🔑 ' + _('Encrypted') : (!!item.title)? item.title + '' : Note.defaultTitle(item);
|
||||
}
|
||||
|
||||
static async markAllNonEncryptedForSync() {
|
||||
@@ -704,6 +708,7 @@ class BaseItem extends BaseModel {
|
||||
}
|
||||
|
||||
BaseItem.encryptionService_ = null;
|
||||
BaseItem.revisionService_ = null;
|
||||
|
||||
// Also update:
|
||||
// - itemsThatNeedSync()
|
||||
@@ -716,6 +721,7 @@ BaseItem.syncItemDefinitions_ = [
|
||||
{ type: BaseModel.TYPE_TAG, className: 'Tag' },
|
||||
{ type: BaseModel.TYPE_NOTE_TAG, className: 'NoteTag' },
|
||||
{ type: BaseModel.TYPE_MASTER_KEY, className: 'MasterKey' },
|
||||
{ type: BaseModel.TYPE_REVISION, className: 'Revision' },
|
||||
];
|
||||
|
||||
module.exports = BaseItem;
|
@@ -11,7 +11,10 @@ class ItemChange extends BaseModel {
|
||||
return BaseModel.TYPE_ITEM_CHANGE;
|
||||
}
|
||||
|
||||
static async add(itemType, itemId, type) {
|
||||
static async add(itemType, itemId, type, changeSource = null, beforeChangeItemJson = null) {
|
||||
if (changeSource === null) changeSource = ItemChange.SOURCE_UNSPECIFIED;
|
||||
if (!beforeChangeItemJson) beforeChangeItemJson = '';
|
||||
|
||||
ItemChange.saveCalls_.push(true);
|
||||
|
||||
// Using a mutex so that records can be added to the database in the
|
||||
@@ -21,7 +24,7 @@ class ItemChange extends BaseModel {
|
||||
try {
|
||||
await this.db().transactionExecBatch([
|
||||
{ sql: 'DELETE FROM item_changes WHERE item_id = ?', params: [itemId] },
|
||||
{ sql: 'INSERT INTO item_changes (item_type, item_id, type, created_time) VALUES (?, ?, ?, ?)', params: [itemType, itemId, type, Date.now()] },
|
||||
{ sql: 'INSERT INTO item_changes (item_type, item_id, type, source, created_time, before_change_item) VALUES (?, ?, ?, ?, ?, ?)', params: [itemType, itemId, type, changeSource, Date.now(), beforeChangeItemJson] },
|
||||
]);
|
||||
} finally {
|
||||
release();
|
||||
@@ -61,4 +64,7 @@ ItemChange.TYPE_CREATE = 1;
|
||||
ItemChange.TYPE_UPDATE = 2;
|
||||
ItemChange.TYPE_DELETE = 3;
|
||||
|
||||
ItemChange.SOURCE_UNSPECIFIED = 1;
|
||||
ItemChange.SOURCE_SYNC = 2;
|
||||
|
||||
module.exports = ItemChange;
|
@@ -525,14 +525,32 @@ class Note extends BaseItem {
|
||||
return this.save(newNote);
|
||||
}
|
||||
|
||||
static async noteIsOlderThan(noteId, date) {
|
||||
const n = await this.db().selectOne('SELECT updated_time FROM notes WHERE id = ?', [noteId]);
|
||||
if (!n) throw new Error('No such note: ' + noteId);
|
||||
return n.updated_time < date;
|
||||
}
|
||||
|
||||
static async save(o, options = null) {
|
||||
let isNew = this.isNew(o, options);
|
||||
if (isNew && !o.source) o.source = Setting.value('appName');
|
||||
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
|
||||
|
||||
// We only keep the previous note content for "old notes" (see Revision Service for more info)
|
||||
// In theory, we could simply save all the previous note contents, and let the revision service
|
||||
// decide what to keep and what to ignore, but in practice keeping the previous content is a bit
|
||||
// heavy - the note needs to be reloaded here, the JSON blob needs to be saved, etc.
|
||||
// So the check for old note here is basically an optimisation.
|
||||
let beforeNoteJson = null;
|
||||
if (!isNew && this.revisionService().isOldNote(o.id)) {
|
||||
beforeNoteJson = await Note.load(o.id);
|
||||
if (beforeNoteJson) beforeNoteJson = JSON.stringify(beforeNoteJson);
|
||||
}
|
||||
|
||||
const note = await super.save(o, options);
|
||||
|
||||
ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE);
|
||||
const changeSource = options && options.changeSource ? options.changeSource : null;
|
||||
ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson);
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
@@ -550,16 +568,29 @@ class Note extends BaseItem {
|
||||
}
|
||||
|
||||
static async batchDelete(ids, options = null) {
|
||||
const result = await super.batchDelete(ids, options);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
ItemChange.add(BaseModel.TYPE_NOTE, ids[i], ItemChange.TYPE_DELETE);
|
||||
ids = ids.slice();
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTE_DELETE',
|
||||
id: ids[i],
|
||||
});
|
||||
while (ids.length) {
|
||||
const processIds = ids.splice(0, 50);
|
||||
|
||||
const notes = await Note.byIds(processIds);
|
||||
const beforeChangeItems = {};
|
||||
for (const note of notes) {
|
||||
beforeChangeItems[note.id] = JSON.stringify(note);
|
||||
}
|
||||
|
||||
const result = await super.batchDelete(processIds, options);
|
||||
const changeSource = options && options.changeSource ? options.changeSource : null;
|
||||
for (let i = 0; i < processIds.length; i++) {
|
||||
const id = processIds[i];
|
||||
ItemChange.add(BaseModel.TYPE_NOTE, id, ItemChange.TYPE_DELETE, changeSource, beforeChangeItems[id]);
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTE_DELETE',
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static dueNotes() {
|
||||
|
248
ReactNativeClient/lib/models/Revision.js
Normal file
248
ReactNativeClient/lib/models/Revision.js
Normal file
@@ -0,0 +1,248 @@
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { _ } = require('lib/locale');
|
||||
const DiffMatchPatch = require('diff-match-patch');
|
||||
const ArrayUtils = require('lib/ArrayUtils.js');
|
||||
const JoplinError = require('lib/JoplinError');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
|
||||
const dmp = new DiffMatchPatch();
|
||||
|
||||
class Revision extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'revisions';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_REVISION;
|
||||
}
|
||||
|
||||
static createTextPatch(oldText, newText) {
|
||||
return dmp.patch_toText(dmp.patch_make(oldText, newText));
|
||||
}
|
||||
|
||||
static applyTextPatch(text, patch) {
|
||||
patch = dmp.patch_fromText(patch);
|
||||
const result = dmp.patch_apply(patch, text);
|
||||
if (!result || !result.length) throw new Error('Could not apply patch');
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static createObjectPatch(oldObject, newObject) {
|
||||
if (!oldObject) oldObject = {};
|
||||
|
||||
const output = {
|
||||
new: {},
|
||||
deleted: [],
|
||||
};
|
||||
|
||||
for (let k in newObject) {
|
||||
if (!newObject.hasOwnProperty(k)) continue;
|
||||
if (oldObject[k] === newObject[k]) continue;
|
||||
output.new[k] = newObject[k];
|
||||
}
|
||||
|
||||
for (let k in oldObject) {
|
||||
if (!oldObject.hasOwnProperty(k)) continue;
|
||||
if (!(k in newObject)) output.deleted.push(k);
|
||||
}
|
||||
|
||||
return JSON.stringify(output);
|
||||
}
|
||||
|
||||
static applyObjectPatch(object, patch) {
|
||||
patch = JSON.parse(patch);
|
||||
const output = Object.assign({}, object);
|
||||
|
||||
for (let k in patch.new) {
|
||||
output[k] = patch.new[k];
|
||||
}
|
||||
|
||||
for (let i = 0; i < patch.deleted.length; i++) {
|
||||
delete output[patch.deleted[i]];
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async countRevisions(itemType, itemId) {
|
||||
const r = await this.db().selectOne('SELECT count(*) as total FROM revisions WHERE item_type = ? AND item_id = ?', [
|
||||
itemType,
|
||||
itemId,
|
||||
]);
|
||||
|
||||
return r ? r.total : 0;
|
||||
}
|
||||
|
||||
static latestRevision(itemType, itemId) {
|
||||
return this.modelSelectOne('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time DESC LIMIT 1', [
|
||||
itemType,
|
||||
itemId,
|
||||
]);
|
||||
}
|
||||
|
||||
static allByType(itemType, itemId) {
|
||||
return this.modelSelectAll('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time ASC', [
|
||||
itemType,
|
||||
itemId,
|
||||
]);
|
||||
}
|
||||
|
||||
static async itemsWithRevisions(itemType, itemIds) {
|
||||
if (!itemIds.length) return [];
|
||||
const rows = await this.db().selectAll('SELECT distinct item_id FROM revisions WHERE item_type = ? AND item_id IN ("' + itemIds.join('","') + '")', [
|
||||
itemType,
|
||||
]);
|
||||
|
||||
return rows.map(r => r.item_id);
|
||||
}
|
||||
|
||||
static async itemsWithNoRevisions(itemType, itemIds) {
|
||||
const withRevs = await this.itemsWithRevisions(itemType, itemIds);
|
||||
const output = [];
|
||||
for (let i = 0; i < itemIds.length; i++) {
|
||||
if (withRevs.indexOf(itemIds[i]) < 0) output.push(itemIds[i]);
|
||||
}
|
||||
return ArrayUtils.unique(output);
|
||||
}
|
||||
|
||||
static moveRevisionToTop(revision, revs) {
|
||||
let targetIndex = -1;
|
||||
for (let i = revs.length - 1; i >= 0; i--) {
|
||||
const rev = revs[i];
|
||||
if (rev.id === revision.id) {
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex < 0) throw new Error('Could not find revision: ' + revision.id);
|
||||
|
||||
if (targetIndex !== revs.length - 1) {
|
||||
revs = revs.slice();
|
||||
const toTop = revs[targetIndex];
|
||||
revs.splice(targetIndex, 1);
|
||||
revs.push(toTop);
|
||||
}
|
||||
|
||||
return revs;
|
||||
}
|
||||
|
||||
// Note: revs must be sorted by update_time ASC (as returned by allByType)
|
||||
static async mergeDiffs(revision, revs = null) {
|
||||
if (!('encryption_applied' in revision) || !!revision.encryption_applied) throw new JoplinError('Target revision is encrypted', 'revision_encrypted');
|
||||
|
||||
if (!revs) {
|
||||
revs = await this.modelSelectAll('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? AND item_updated_time <= ? ORDER BY item_updated_time ASC', [
|
||||
revision.item_type,
|
||||
revision.item_id,
|
||||
revision.item_updated_time,
|
||||
]);
|
||||
} else {
|
||||
revs = revs.slice();
|
||||
}
|
||||
|
||||
// Handle rare case where two revisions have been created at exactly the same millisecond
|
||||
// Also handle even rarer case where a rev and its parent have been created at the
|
||||
// same milliseconds. All code below expects target revision to be on top.
|
||||
revs = this.moveRevisionToTop(revision, revs);
|
||||
|
||||
const output = {
|
||||
title: '',
|
||||
body: '',
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
// Build up the list of revisions that are parents of the target revision.
|
||||
const revIndexes = [revs.length - 1];
|
||||
let parentId = revision.parent_id;
|
||||
for (let i = revs.length - 2; i >= 0; i--) {
|
||||
const rev = revs[i];
|
||||
if (rev.id !== parentId) continue;
|
||||
parentId = rev.parent_id;
|
||||
revIndexes.push(i);
|
||||
}
|
||||
revIndexes.reverse();
|
||||
|
||||
for (const revIndex of revIndexes) {
|
||||
const rev = revs[revIndex];
|
||||
if (!!rev.encryption_applied) throw new JoplinError(sprintf('Revision "%s" is encrypted', rev.id), 'revision_encrypted');
|
||||
output.title = this.applyTextPatch(output.title, rev.title_diff);
|
||||
output.body = this.applyTextPatch(output.body, rev.body_diff);
|
||||
output.metadata = this.applyObjectPatch(output.metadata, rev.metadata_diff);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async deleteOldRevisions(ttl) {
|
||||
// When deleting old revisions, we need to make sure that the oldest surviving revision
|
||||
// is a "merged" one (as opposed to a diff from a now deleted revision). So every time
|
||||
// we deleted a revision, we need to find if there's a corresponding surviving revision
|
||||
// and modify that revision into a "merged" one.
|
||||
|
||||
const cutOffDate = Date.now() - ttl;
|
||||
const revisions = await this.modelSelectAll('SELECT * FROM revisions WHERE item_updated_time < ? ORDER BY item_updated_time DESC', [cutOffDate]);
|
||||
const doneItems = {};
|
||||
|
||||
for (const rev of revisions) {
|
||||
const doneKey = rev.item_type + '_' + rev.item_id;
|
||||
if (doneItems[doneKey]) continue;
|
||||
|
||||
const keptRev = await this.modelSelectOne('SELECT * FROM revisions WHERE item_updated_time >= ? AND item_type = ? AND item_id = ? ORDER BY item_updated_time ASC LIMIT 1', [
|
||||
cutOffDate,
|
||||
rev.item_type,
|
||||
rev.item_id,
|
||||
]);
|
||||
|
||||
try {
|
||||
const deleteQueryCondition = 'item_updated_time < ? AND item_id = ?';
|
||||
const deleteQueryParams = [cutOffDate, rev.item_id];
|
||||
const deleteQuery = { sql: 'DELETE FROM revisions WHERE ' + deleteQueryCondition, params: deleteQueryParams };
|
||||
|
||||
if (!keptRev) {
|
||||
const hasEncrypted = await this.modelSelectOne('SELECT * FROM revisions WHERE encryption_applied = 1 AND ' + deleteQueryCondition, deleteQueryParams);
|
||||
if (!!hasEncrypted) throw new JoplinError('One of the revision to be deleted is encrypted', 'revision_encrypted');
|
||||
await this.db().transactionExecBatch([deleteQuery]);
|
||||
} else {
|
||||
// Note: we don't need to check for encrypted rev here because
|
||||
// mergeDiff will already throw the revision_encrypted exception
|
||||
// if a rev is encrypted.
|
||||
const merged = await this.mergeDiffs(keptRev);
|
||||
|
||||
const queries = [
|
||||
deleteQuery,
|
||||
{ sql: 'UPDATE revisions SET title_diff = ?, body_diff = ?, metadata_diff = ? WHERE id = ?', params: [
|
||||
this.createTextPatch('', merged.title),
|
||||
this.createTextPatch('', merged.body),
|
||||
this.createObjectPatch({}, merged.metadata),
|
||||
keptRev.id,
|
||||
] },
|
||||
];
|
||||
|
||||
await this.db().transactionExecBatch(queries);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'revision_encrypted') {
|
||||
this.logger().info('Aborted deletion of old revisions for item ' + rev.item_id + ' because one of the revisions is still encrypted', error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
doneItems[doneKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
static async revisionExists(itemType, itemId, updatedTime) {
|
||||
const existingRev = await Revision.latestRevision(itemType, itemId);
|
||||
return existingRev && existingRev.item_updated_time === updatedTime;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Revision;
|
@@ -191,7 +191,15 @@ class Setting extends BaseModel {
|
||||
|
||||
'resourceService.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false },
|
||||
'searchEngine.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false },
|
||||
'revisionService.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false },
|
||||
|
||||
'searchEngine.initialIndexingDone': { value: false, type: Setting.TYPE_BOOL, public: false },
|
||||
|
||||
'revisionService.enabled': { section: 'revisionService', value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Enable note history') },
|
||||
'revisionService.ttlDays': { section: 'revisionService', value: 90, type: Setting.TYPE_INT, public: true, minimum: 1, maximum: 365 * 2, step: 1, unitLabel: (value = null) => { return value === null ? _('days') : _('%d days', value) }, label: () => _('Keep note history for') },
|
||||
'revisionService.intervalBetweenRevisions': { section: 'revisionService', value: 1000 * 60 * 10, type: Setting.TYPE_INT, public: false },
|
||||
'revisionService.oldNoteInterval': { section: 'revisionService', value: 1000 * 60 * 60 * 24 * 7, type: Setting.TYPE_INT, public: false },
|
||||
|
||||
'welcome.wasBuilt': { value: false, type: Setting.TYPE_BOOL, public: false },
|
||||
};
|
||||
|
||||
@@ -586,6 +594,7 @@ class Setting extends BaseModel {
|
||||
if (name === 'note') return _('Note');
|
||||
if (name === 'plugins') return _('Plugins');
|
||||
if (name === 'application') return _('Application');
|
||||
if (name === 'revisionService') return _('Note History');
|
||||
return name;
|
||||
}
|
||||
|
||||
|
@@ -7,6 +7,7 @@ const { splitCommandString } = require('lib/string-utils');
|
||||
const { fileExtension } = require('lib/path-utils');
|
||||
const spawn = require('child_process').spawn;
|
||||
const chokidar = require('chokidar');
|
||||
// const chokidar = null;
|
||||
|
||||
class ExternalEditWatcher {
|
||||
|
||||
|
@@ -7,6 +7,7 @@ class ItemChangeUtils {
|
||||
const lastProcessedChangeIds = [
|
||||
Setting.value('resourceService.lastProcessedChangeId'),
|
||||
Setting.value('searchEngine.lastProcessedChangeId'),
|
||||
Setting.value('revisionService.lastProcessedChangeId'),
|
||||
];
|
||||
|
||||
const lowestChangeId = Math.min(...lastProcessedChangeIds);
|
||||
|
@@ -93,6 +93,7 @@ class ResourceService extends BaseService {
|
||||
}
|
||||
|
||||
async deleteOrphanResources(expiryDelay = null) {
|
||||
if (expiryDelay === null) expiryDelay = Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000;
|
||||
const resourceIds = await NoteResource.orphanResources(expiryDelay);
|
||||
this.logger().info('ResourceService::deleteOrphanResources:', resourceIds);
|
||||
for (let i = 0; i < resourceIds.length; i++) {
|
||||
|
265
ReactNativeClient/lib/services/RevisionService.js
Normal file
265
ReactNativeClient/lib/services/RevisionService.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const ItemChange = require('lib/models/ItemChange');
|
||||
const Note = require('lib/models/Note');
|
||||
const Folder = require('lib/models/Folder');
|
||||
const Setting = require('lib/models/Setting');
|
||||
const Revision = require('lib/models/Revision');
|
||||
const BaseModel = require('lib/BaseModel');
|
||||
const ItemChangeUtils = require('lib/services/ItemChangeUtils');
|
||||
const { shim } = require('lib/shim');
|
||||
const BaseService = require('lib/services/BaseService');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const ArrayUtils = require('lib/ArrayUtils.js');
|
||||
|
||||
class RevisionService extends BaseService {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// An "old note" is one that has been created before the revision service existed. These
|
||||
// notes never benefited from revisions so the first time they are modified, a copy of
|
||||
// the original note is saved. The goal is to have at least one revision in case the note
|
||||
// is deleted or modified as a result of a bug or user mistake.
|
||||
this.isOldNotesCache_ = {};
|
||||
}
|
||||
|
||||
static instance() {
|
||||
if (this.instance_) return this.instance_;
|
||||
this.instance_ = new RevisionService();
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
oldNoteCutOffDate_() {
|
||||
return Date.now() - Setting.value('revisionService.oldNoteInterval');
|
||||
}
|
||||
|
||||
async isOldNote(noteId) {
|
||||
if (noteId in this.isOldNotesCache_) return this.isOldNotesCache_[noteId];
|
||||
|
||||
const isOld = await Note.noteIsOlderThan(noteId, this.oldNoteCutOffDate_());
|
||||
this.isOldNotesCache_[noteId] = isOld;
|
||||
return isOld;
|
||||
}
|
||||
|
||||
noteMetadata_(note) {
|
||||
const excludedFields = ['type_', 'title', 'body', 'created_time', 'updated_time', 'encryption_applied', 'encryption_cipher_text', 'is_conflict'];
|
||||
const md = {};
|
||||
for (let k in note) {
|
||||
if (excludedFields.indexOf(k) >= 0) continue;
|
||||
md[k] = note[k];
|
||||
}
|
||||
|
||||
if (note.user_updated_time === note.updated_time) delete md.user_updated_time;
|
||||
if (note.user_created_time === note.created_time) delete md.user_created_time;
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
isEmptyRevision_(rev) {
|
||||
if (!!rev.title_diff) return false;
|
||||
if (!!rev.body_diff) return false;
|
||||
|
||||
const md = JSON.parse(rev.metadata_diff);
|
||||
if (md.new && Object.keys(md.new).length) return false;
|
||||
if (md.deleted && Object.keys(md.deleted).length) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async createNoteRevision_(note, parentRevId = null) {
|
||||
const parentRev = parentRevId ? await Revision.load(parentRevId) : await Revision.latestRevision(BaseModel.TYPE_NOTE, note.id);
|
||||
|
||||
const output = {
|
||||
parent_id: '',
|
||||
item_type: BaseModel.TYPE_NOTE,
|
||||
item_id: note.id,
|
||||
item_updated_time: note.updated_time,
|
||||
};
|
||||
|
||||
const noteMd = this.noteMetadata_(note);
|
||||
const noteTitle = note.title ? note.title : '';
|
||||
const noteBody = note.body ? note.body : '';
|
||||
|
||||
if (!parentRev) {
|
||||
output.title_diff = Revision.createTextPatch('', noteTitle);
|
||||
output.body_diff = Revision.createTextPatch('', noteBody);
|
||||
output.metadata_diff = Revision.createObjectPatch({}, noteMd);
|
||||
} else {
|
||||
if (Date.now() - parentRev.updated_time < Setting.value('revisionService.intervalBetweenRevisions')) return null;
|
||||
|
||||
const merged = await Revision.mergeDiffs(parentRev);
|
||||
output.parent_id = parentRev.id;
|
||||
output.title_diff = Revision.createTextPatch(merged.title, noteTitle);
|
||||
output.body_diff = Revision.createTextPatch(merged.body, noteBody);
|
||||
output.metadata_diff = Revision.createObjectPatch(merged.metadata, noteMd);
|
||||
}
|
||||
|
||||
if (this.isEmptyRevision_(output)) return null;
|
||||
|
||||
return Revision.save(output);
|
||||
}
|
||||
|
||||
async collectRevisions() {
|
||||
if (this.isCollecting_) return;
|
||||
|
||||
this.isCollecting_ = true;
|
||||
|
||||
await ItemChange.waitForAllSaved();
|
||||
|
||||
const doneNoteIds = [];
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// See synchronizer test units to see why changes coming
|
||||
// from sync are skipped.
|
||||
const changes = await ItemChange.modelSelectAll(`
|
||||
SELECT id, item_id, type, before_change_item
|
||||
FROM item_changes
|
||||
WHERE item_type = ?
|
||||
AND source != ?
|
||||
AND id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 10
|
||||
`, [BaseModel.TYPE_NOTE, ItemChange.SOURCE_SYNC, Setting.value('revisionService.lastProcessedChangeId')]);
|
||||
|
||||
if (!changes.length) break;
|
||||
|
||||
const noteIds = changes.map(a => a.item_id);
|
||||
const notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND encryption_applied = 0 AND id IN ("' + noteIds.join('","') + '")');
|
||||
|
||||
for (let i = 0; i < changes.length; i++) {
|
||||
const change = changes[i];
|
||||
const noteId = change.item_id;
|
||||
|
||||
if (change.type === ItemChange.TYPE_UPDATE && doneNoteIds.indexOf(noteId) < 0) {
|
||||
const note = BaseModel.byId(notes, noteId);
|
||||
const oldNote = change.before_change_item ? JSON.parse(change.before_change_item) : null;
|
||||
|
||||
if (note) {
|
||||
if (oldNote && oldNote.updated_time < this.oldNoteCutOffDate_()) {
|
||||
// This is where we save the original version of this old note
|
||||
await this.createNoteRevision_(oldNote);
|
||||
}
|
||||
|
||||
await this.createNoteRevision_(note);
|
||||
doneNoteIds.push(noteId);
|
||||
this.isOldNotesCache_[noteId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (change.type === ItemChange.TYPE_DELETE && !!change.before_change_item) {
|
||||
const note = JSON.parse(change.before_change_item);
|
||||
const revExists = await Revision.revisionExists(BaseModel.TYPE_NOTE, note.id, note.updated_time);
|
||||
if (!revExists) await this.createNoteRevision_(note);
|
||||
doneNoteIds.push(noteId);
|
||||
}
|
||||
|
||||
Setting.setValue('revisionService.lastProcessedChangeId', change.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'revision_encrypted') {
|
||||
// One or more revisions are encrypted - stop processing for now
|
||||
// and these revisions will be processed next time the revision
|
||||
// collector runs.
|
||||
this.logger().info('RevisionService::collectRevisions: One or more revision was encrypted. Processing was stopped but will resume later when the revision is decrypted.', error);
|
||||
} else {
|
||||
this.logger().error('RevisionService::collectRevisions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await Setting.saveAll();
|
||||
await ItemChangeUtils.deleteProcessedChanges();
|
||||
|
||||
this.isCollecting_ = false;
|
||||
|
||||
this.logger().info('RevisionService::collectRevisions: Created revisions for ' + doneNoteIds.length + ' notes');
|
||||
}
|
||||
|
||||
async deleteOldRevisions(ttl) {
|
||||
return Revision.deleteOldRevisions(ttl);
|
||||
}
|
||||
|
||||
async revisionNote(revisions, index) {
|
||||
if (index < 0 || index >= revisions.length) throw new Error('Invalid revision index: ' + index);
|
||||
|
||||
const rev = revisions[index];
|
||||
const merged = await Revision.mergeDiffs(rev, revisions);
|
||||
|
||||
const output = Object.assign({
|
||||
title: merged.title,
|
||||
body: merged.body,
|
||||
}, merged.metadata);
|
||||
output.updated_time = output.user_updated_time;
|
||||
output.created_time = output.user_created_time;
|
||||
output.type_ = BaseModel.TYPE_NOTE;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
restoreFolderTitle() {
|
||||
return _('Restored Notes');
|
||||
}
|
||||
|
||||
async restoreFolder() {
|
||||
let folder = await Folder.loadByTitle(this.restoreFolderTitle());
|
||||
if (!folder) {
|
||||
folder = await Folder.save({ title: this.restoreFolderTitle() });
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
async importRevisionNote(note) {
|
||||
const toImport = Object.assign({}, note);
|
||||
delete toImport.id;
|
||||
delete toImport.updated_time;
|
||||
delete toImport.created_time;
|
||||
delete toImport.encryption_applied;
|
||||
delete toImport.encryption_cipher_text;
|
||||
|
||||
const folder = await this.restoreFolder();
|
||||
|
||||
toImport.parent_id = folder.id;
|
||||
|
||||
await Note.save(toImport);
|
||||
}
|
||||
|
||||
async maintenance() {
|
||||
const startTime = Date.now();
|
||||
this.logger().info('RevisionService::maintenance: Starting...');
|
||||
|
||||
if (!Setting.value('revisionService.enabled')) {
|
||||
this.logger().info('RevisionService::maintenance: Service is disabled');
|
||||
// We do as if we had processed all the latest changes so that they can be cleaned up
|
||||
// later on by ItemChangeUtils.deleteProcessedChanges().
|
||||
Setting.setValue('revisionService.lastProcessedChangeId', await ItemChange.lastChangeId());
|
||||
await this.deleteOldRevisions(Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
this.logger().info('RevisionService::maintenance: Service is enabled');
|
||||
await this.collectRevisions();
|
||||
await this.deleteOldRevisions(Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
this.logger().info('RevisionService::maintenance: Done in ' + (Date.now() - startTime) + 'ms');
|
||||
}
|
||||
|
||||
runInBackground(collectRevisionInterval = null) {
|
||||
if (this.isRunningInBackground_) return;
|
||||
this.isRunningInBackground_ = true;
|
||||
|
||||
if (collectRevisionInterval === null) collectRevisionInterval = 1000 * 60 * 10;
|
||||
|
||||
this.logger().info('RevisionService::runInBackground: Starting background service with revision collection interval ' + collectRevisionInterval);
|
||||
|
||||
setTimeout(() => {
|
||||
this.maintenance();
|
||||
}, 1000 * 4);
|
||||
|
||||
shim.setInterval(() => {
|
||||
this.maintenance();
|
||||
}, collectRevisionInterval);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = RevisionService;
|
@@ -2,6 +2,7 @@ const BaseItem = require('lib/models/BaseItem.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const ItemChange = require('lib/models/ItemChange.js');
|
||||
const ResourceLocalState = require('lib/models/ResourceLocalState.js');
|
||||
const MasterKey = require('lib/models/MasterKey.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
@@ -325,7 +326,7 @@ class Synchronizer {
|
||||
if (action == "createRemote" || action == "updateRemote") {
|
||||
let canSync = true;
|
||||
try {
|
||||
if (this.testingHooks_.indexOf("rejectedByTarget") >= 0) throw new JoplinError("Testing rejectedByTarget", "rejectedByTarget");
|
||||
if (this.testingHooks_.indexOf("notesRejectedByTarget") >= 0 && local.type_ === BaseModel.TYPE_NOTE) throw new JoplinError("Testing rejectedByTarget", "rejectedByTarget");
|
||||
const content = await ItemClass.serializeForSync(local);
|
||||
await this.api().put(path, content);
|
||||
} catch (error) {
|
||||
@@ -370,9 +371,9 @@ class Synchronizer {
|
||||
local = remoteContent;
|
||||
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||
await ItemClass.save(local, { autoTimestamp: false, nextQueries: syncTimeQueries });
|
||||
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
||||
} else {
|
||||
await ItemClass.delete(local.id);
|
||||
await ItemClass.delete(local.id, { changeSource: ItemChange.SOURCE_SYNC });
|
||||
}
|
||||
} else if (action == "noteConflict") {
|
||||
// ------------------------------------------------------------------------------
|
||||
@@ -395,7 +396,7 @@ class Synchronizer {
|
||||
let conflictedNote = Object.assign({}, local);
|
||||
delete conflictedNote.id;
|
||||
conflictedNote.is_conflict = 1;
|
||||
await Note.save(conflictedNote, { autoTimestamp: false });
|
||||
await Note.save(conflictedNote, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
@@ -406,12 +407,12 @@ class Synchronizer {
|
||||
if (remote) {
|
||||
local = remoteContent;
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||
await ItemClass.save(local, { autoTimestamp: false, nextQueries: syncTimeQueries });
|
||||
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
||||
|
||||
if (!!local.encryption_applied) this.dispatch({ type: "SYNC_GOT_ENCRYPTED_ITEM" });
|
||||
} else {
|
||||
// Remote no longer exists (note deleted) so delete local one too
|
||||
await ItemClass.delete(local.id);
|
||||
await ItemClass.delete(local.id, { changeSource: ItemChange.SOURCE_SYNC });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +536,8 @@ class Synchronizer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.testingHooks_.indexOf('skipRevisions') >= 0 && content && content.type_ === BaseModel.TYPE_REVISION) action = null;
|
||||
|
||||
if (!action) continue;
|
||||
|
||||
this.logSyncOperation(action, local, remote, reason);
|
||||
@@ -557,30 +560,13 @@ class Synchronizer {
|
||||
let options = {
|
||||
autoTimestamp: false,
|
||||
nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, content, time.unixMs()),
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
};
|
||||
if (action == "createLocal") options.isNew = true;
|
||||
if (action == "updateLocal") options.oldItem = local;
|
||||
|
||||
const creatingNewResource = content.type_ == BaseModel.TYPE_RESOURCE && action == "createLocal";
|
||||
|
||||
// if (content.type_ == BaseModel.TYPE_RESOURCE && action == "createLocal") {
|
||||
// let localResourceContentPath = Resource.fullPath(content);
|
||||
// let remoteResourceContentPath = this.resourceDirName_ + "/" + content.id;
|
||||
// try {
|
||||
// await this.api().get(remoteResourceContentPath, { path: localResourceContentPath, target: "file" });
|
||||
// } catch (error) {
|
||||
// if (error.code === 'rejectedByTarget') {
|
||||
// this.progressReport_.errors.push(error);
|
||||
// this.logger().warn('Rejected by target: ' + path + ': ' + error.message);
|
||||
// continue;
|
||||
// } else {
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (creatingNewResource) content.fetch_status = Resource.FETCH_STATUS_IDLE;
|
||||
|
||||
if (creatingNewResource) {
|
||||
await ResourceLocalState.save({ resource_id: content.id, fetch_status: Resource.FETCH_STATUS_IDLE });
|
||||
}
|
||||
@@ -608,7 +594,7 @@ class Synchronizer {
|
||||
}
|
||||
|
||||
let ItemClass = BaseItem.itemClass(local.type_);
|
||||
await ItemClass.delete(local.id, { trackDeleted: false });
|
||||
await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +639,7 @@ class Synchronizer {
|
||||
// CONFLICT
|
||||
await Folder.markNotesAsConflict(item.id);
|
||||
}
|
||||
await Folder.delete(item.id, { deleteChildren: false, trackDeleted: false });
|
||||
await Folder.delete(item.id, { deleteChildren: false, changeSource: ItemChange.SOURCE_SYNC, trackDeleted: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
52
ReactNativeClient/package-lock.json
generated
52
ReactNativeClient/package-lock.json
generated
@@ -2325,6 +2325,11 @@
|
||||
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
|
||||
"integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E="
|
||||
},
|
||||
"diff-match-patch": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.4.tgz",
|
||||
"integrity": "sha512-Uv3SW8bmH9nAtHKaKSanOQmj2DnlH65fUpcrMdfdaOxUG02QQ4YGZ8AE7kKOMisF7UqvOlGKVYWRvezdncW9lg=="
|
||||
},
|
||||
"dom-walk": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz",
|
||||
@@ -2680,7 +2685,8 @@
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -2698,11 +2704,13 @@
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -2715,15 +2723,18 @@
|
||||
},
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -2826,7 +2837,8 @@
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
@@ -2836,6 +2848,7 @@
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -2848,17 +2861,20 @@
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.2.4",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.1",
|
||||
"yallist": "^3.0.0"
|
||||
@@ -2875,6 +2891,7 @@
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
@@ -2947,7 +2964,8 @@
|
||||
},
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -2957,6 +2975,7 @@
|
||||
"once": {
|
||||
"version": "1.4.0",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -3032,7 +3051,8 @@
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.1.1",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -3062,6 +3082,7 @@
|
||||
"string-width": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -3079,6 +3100,7 @@
|
||||
"strip-ansi": {
|
||||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -3117,11 +3139,13 @@
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4123,7 +4147,7 @@
|
||||
},
|
||||
"load-json-file": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
|
||||
"resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
|
||||
"integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
|
||||
"requires": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
@@ -5278,7 +5302,7 @@
|
||||
},
|
||||
"load-json-file": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
|
||||
"resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
|
||||
"integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
|
||||
"requires": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
@@ -7372,7 +7396,7 @@
|
||||
"dependencies": {
|
||||
"uuid": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz",
|
||||
"resolved": "http://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz",
|
||||
"integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE="
|
||||
}
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@
|
||||
"base-64": "^0.1.0",
|
||||
"buffer": "^5.0.8",
|
||||
"diacritics": "^1.3.0",
|
||||
"diff-match-patch": "^1.0.4",
|
||||
"events": "^1.1.1",
|
||||
"form-data": "^2.1.4",
|
||||
"highlight.js": "^9.15.6",
|
||||
|
@@ -22,9 +22,11 @@ const Tag = require('lib/models/Tag.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const MasterKey = require('lib/models/MasterKey.js');
|
||||
const Revision = require('lib/models/Revision.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const BaseService = require('lib/services/BaseService.js');
|
||||
const ResourceService = require('lib/services/ResourceService');
|
||||
const RevisionService = require('lib/services/RevisionService');
|
||||
const { JoplinDatabase } = require('lib/joplin-database.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { NotesScreen } = require('lib/components/screens/notes.js');
|
||||
@@ -396,6 +398,7 @@ async function initialize(dispatch) {
|
||||
BaseItem.loadClass('Tag', Tag);
|
||||
BaseItem.loadClass('NoteTag', NoteTag);
|
||||
BaseItem.loadClass('MasterKey', MasterKey);
|
||||
BaseItem.loadClass('Revision', Revision);
|
||||
|
||||
const fsDriver = new FsDriverRN();
|
||||
|
||||
@@ -430,6 +433,8 @@ async function initialize(dispatch) {
|
||||
reg.logger().info('db.ftsEnabled = ', Setting.value('db.ftsEnabled'));
|
||||
}
|
||||
|
||||
BaseItem.revisionService_ = RevisionService.instance();
|
||||
|
||||
// Note: for now we hard-code the folder sort order as we need to
|
||||
// create a UI to allow customisation (started in branch mobile_add_sidebar_buttons)
|
||||
Setting.setValue('folders.sortOrder.field', 'title');
|
||||
@@ -526,6 +531,10 @@ async function initialize(dispatch) {
|
||||
|
||||
await WelcomeUtils.install(dispatch);
|
||||
|
||||
// Collect revisions more frequently on mobile because it doesn't auto-save
|
||||
// and it cannot collect anything when the app is not active.
|
||||
RevisionService.instance().runInBackground(1000 * 30);
|
||||
|
||||
reg.logger().info('Application initialized');
|
||||
}
|
||||
|
||||
|
@@ -4,8 +4,6 @@
|
||||
|
||||
// (Desktop|Mobile|Android|iOS[CLI): (New|Improved|Fixed): Some message..... (#ISSUE)
|
||||
|
||||
// Requires git2json: https://github.com/tarmstrong/git2json
|
||||
|
||||
require('app-module-path').addPath(__dirname + '/../ReactNativeClient');
|
||||
|
||||
const rootDir = __dirname + '/..';
|
||||
@@ -31,11 +29,22 @@ async function gitTags() {
|
||||
return output;
|
||||
}
|
||||
|
||||
async function gitLog() {
|
||||
await execCommand('git2json > gitlog.json');
|
||||
const output = await fs.readJson(rootDir + '/gitlog.json');
|
||||
if (!output || !output.length) throw new Error('Could not read git log or could not generate gitlog.json');
|
||||
await fs.remove('gitlog.json');
|
||||
async function gitLog(sinceTag) {
|
||||
let lines = await execCommand('git log --pretty=format:"%H:%s" ' + sinceTag + '..HEAD');
|
||||
lines = lines.split('\n');
|
||||
|
||||
const output = [];
|
||||
for (const line of lines) {
|
||||
const splitted = line.split(':');
|
||||
const commit = splitted[0];
|
||||
const message = line.substr(commit.length + 1).trim();;
|
||||
|
||||
output.push({
|
||||
commit: commit,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -106,9 +115,17 @@ function formatCommitMessage(msg) {
|
||||
|
||||
const splitted = msg.split(':');
|
||||
|
||||
const isPlatformPrefix = prefix => {
|
||||
prefix = prefix.split(',').map(p => p.trim().toLowerCase());
|
||||
for (const p of prefix) {
|
||||
if (['android', 'mobile', 'ios', 'desktop', 'cli', 'clipper', 'all'].indexOf(p) >= 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (splitted.length) {
|
||||
const platform = splitted[0].trim().toLowerCase();
|
||||
if (['android', 'mobile', 'ios', 'desktop', 'cli', 'clipper', 'all'].indexOf(platform) >= 0) {
|
||||
if (isPlatformPrefix(platform)) {
|
||||
splitted.splice(0, 1);
|
||||
}
|
||||
|
||||
@@ -140,13 +157,22 @@ function formatCommitMessage(msg) {
|
||||
};
|
||||
}
|
||||
|
||||
const t = parts[0].trim().toLowerCase();
|
||||
let t = parts[0].trim().toLowerCase();
|
||||
|
||||
parts.splice(0, 1);
|
||||
const message = parts.join(':').trim();
|
||||
let message = parts.join(':').trim();
|
||||
|
||||
let type = null;
|
||||
|
||||
// eg. "All: Resolves #712: New: Support for note history (#1415)"
|
||||
// "Resolves" doesn't tell us if it's new or improved so check the
|
||||
// third token (which in this case is "new").
|
||||
if (t.indexOf('resolves') === 0 && ['new', 'improved', 'fixed'].indexOf(parts[0].trim().toLowerCase()) >= 0) {
|
||||
t = parts[0].trim().toLowerCase();
|
||||
parts.splice(0, 1);
|
||||
message = parts.join(':').trim();
|
||||
}
|
||||
|
||||
if (t.indexOf('fix') === 0) type = 'fixed';
|
||||
if (t.indexOf('new') === 0) type = 'new';
|
||||
if (t.indexOf('improved') === 0) type = 'improved';
|
||||
@@ -195,20 +221,7 @@ async function main() {
|
||||
const sinceTagName = argv._[0];
|
||||
const platform = platformFromTag(sinceTagName);
|
||||
|
||||
const logs = await gitLog();
|
||||
const tags = await gitTags();
|
||||
|
||||
let sinceTagHash = null;
|
||||
for (const tag of tags) {
|
||||
if (tag.name === sinceTagName) {
|
||||
sinceTagHash = tag.hash;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sinceTagHash) throw new Error('Could not find tag: ' + sinceTagName);
|
||||
|
||||
const logsSinceTags = await gitLogSinceTag(logs, sinceTagHash);
|
||||
const logsSinceTags = await gitLog(sinceTagName);
|
||||
const filteredLogs = filterLogs(logsSinceTags, platform);
|
||||
|
||||
let changelog = createChangeLog(filteredLogs);
|
||||
|
@@ -21,3 +21,6 @@ build_script:
|
||||
- yarn dist
|
||||
|
||||
test: off
|
||||
|
||||
# We only want to build tags
|
||||
skip_non_tags: true
|
Reference in New Issue
Block a user