Merge branch 'dev' into sharing_bug_2
| @@ -937,7 +937,6 @@ packages/default-plugins/commands/editPatch.js | ||||
| packages/default-plugins/utils/getCurrentCommitHash.js | ||||
| packages/default-plugins/utils/getPathToPatchFileFor.js | ||||
| packages/default-plugins/utils/readRepositoryJson.js | ||||
| packages/default-plugins/utils/waitForCliInput.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5BuiltInOptions.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js | ||||
| @@ -1649,6 +1648,18 @@ packages/tools/checkIgnoredFiles.js | ||||
| packages/tools/checkLibPaths.test.js | ||||
| packages/tools/checkLibPaths.js | ||||
| packages/tools/convertThemesToCss.js | ||||
| packages/tools/fuzzer/ActionTracker.js | ||||
| packages/tools/fuzzer/Client.js | ||||
| packages/tools/fuzzer/ClientPool.js | ||||
| packages/tools/fuzzer/Server.js | ||||
| packages/tools/fuzzer/constants.js | ||||
| packages/tools/fuzzer/sync-fuzzer.js | ||||
| packages/tools/fuzzer/types.js | ||||
| packages/tools/fuzzer/utils/SeededRandom.js | ||||
| packages/tools/fuzzer/utils/getNumberProperty.js | ||||
| packages/tools/fuzzer/utils/getProperty.js | ||||
| packages/tools/fuzzer/utils/getStringProperty.js | ||||
| packages/tools/fuzzer/utils/retryWithCount.js | ||||
| packages/tools/generate-database-types.js | ||||
| packages/tools/generate-images.js | ||||
| packages/tools/git-changelog.test.js | ||||
|   | ||||
| @@ -23,6 +23,7 @@ module.exports = { | ||||
| 		'FileSystemCreateWritableOptions': 'readonly', | ||||
| 		'FileSystemHandle': 'readonly', | ||||
| 		'IDBTransactionMode': 'readonly', | ||||
| 		'BigInt': 'readonly', | ||||
| 		'globalThis': 'readonly', | ||||
|  | ||||
| 		// ServiceWorker | ||||
|   | ||||
							
								
								
									
										13
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -912,7 +912,6 @@ packages/default-plugins/commands/editPatch.js | ||||
| packages/default-plugins/utils/getCurrentCommitHash.js | ||||
| packages/default-plugins/utils/getPathToPatchFileFor.js | ||||
| packages/default-plugins/utils/readRepositoryJson.js | ||||
| packages/default-plugins/utils/waitForCliInput.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5BuiltInOptions.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js | ||||
| @@ -1624,6 +1623,18 @@ packages/tools/checkIgnoredFiles.js | ||||
| packages/tools/checkLibPaths.test.js | ||||
| packages/tools/checkLibPaths.js | ||||
| packages/tools/convertThemesToCss.js | ||||
| packages/tools/fuzzer/ActionTracker.js | ||||
| packages/tools/fuzzer/Client.js | ||||
| packages/tools/fuzzer/ClientPool.js | ||||
| packages/tools/fuzzer/Server.js | ||||
| packages/tools/fuzzer/constants.js | ||||
| packages/tools/fuzzer/sync-fuzzer.js | ||||
| packages/tools/fuzzer/types.js | ||||
| packages/tools/fuzzer/utils/SeededRandom.js | ||||
| packages/tools/fuzzer/utils/getNumberProperty.js | ||||
| packages/tools/fuzzer/utils/getProperty.js | ||||
| packages/tools/fuzzer/utils/getStringProperty.js | ||||
| packages/tools/fuzzer/utils/retryWithCount.js | ||||
| packages/tools/generate-database-types.js | ||||
| packages/tools/generate-images.js | ||||
| packages/tools/git-changelog.test.js | ||||
|   | ||||
| @@ -1300,4 +1300,9 @@ footer .bottom-links-row p { | ||||
|  | ||||
| :lang(zh-cn) #plans-section .faq { | ||||
| 	display: none; | ||||
| } | ||||
|  | ||||
|  | ||||
| .cfa-button { | ||||
| 	margin-top: 10px; | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/joplin_server_business/main.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 430 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/joplin_server_business/publish.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 36 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/joplin_server_business/self_host.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 434 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/joplin_server_business/share.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 56 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/joplin_server_business/teams.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 43 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/sponsors/BestEtf.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 8.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/sponsors/Freespinny.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 35 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Assets/WebsiteAssets/images/sponsors/HomeworkGuy.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 110 KiB | 
| @@ -1,24 +1,28 @@ | ||||
| <div class="col-12 col-lg-4 account-type-{{priceMonthly.accountType}}"> | ||||
| <div class="col-12 col-lg-4 account-type-{{priceMonthly.accountType}} hosting-type-{{hostingType}}"> | ||||
| 	<div class="price-container {{#featured}}price-container-blue{{/featured}}"> | ||||
| 		<div class="price-row"> | ||||
| 			<div class="plan-type"> | ||||
| 				<img src="{{imageBaseUrl}}/{{iconName}}.png"/> {{title}} | ||||
| 			<div class="price-row"> | ||||
| 				<div class="plan-type"> | ||||
| 					<img src="{{imageBaseUrl}}/{{iconName}}.png"/> {{title}} | ||||
| 				</div> | ||||
|  | ||||
| 				{{#priceMonthly.formattedMonthlyAmount}} | ||||
| 					<div class="plan-price plan-price-monthly"> | ||||
| 						{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub> | ||||
| 					</div> | ||||
|  | ||||
| 					<div class="plan-price plan-price-yearly"> | ||||
| 						{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub> | ||||
| 					</div> | ||||
| 				{{/priceMonthly.formattedMonthlyAmount}} | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="plan-price plan-price-monthly"> | ||||
| 				{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub> | ||||
| 			{{#priceYearly.formattedMonthlyAmount}} | ||||
| 			<div class="plan-price-yearly-per-year"> | ||||
| 				<div> | ||||
| 					({{priceYearly.formattedAmount}}<sub class="per-year"> <span translate>/year</span></sub>) | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="plan-price plan-price-yearly"> | ||||
| 				{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="plan-price-yearly-per-year"> | ||||
| 			<div> | ||||
| 				({{priceYearly.formattedAmount}}<sub class="per-year"> <span translate>/year</span></sub>) | ||||
| 			</div> | ||||
| 		</div> | ||||
| 			{{/priceYearly.formattedMonthlyAmount}} | ||||
|  | ||||
| 		{{#featureLabelsOn}} | ||||
| 			<p><i class="fas fa-check feature feature-on"></i>{{.}}</p> | ||||
| @@ -29,7 +33,11 @@ | ||||
| 		{{/featureLabelsOff}} | ||||
| 		 | ||||
| 		<p class="text-center subscribe-wrapper"> | ||||
| 			<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton">{{cfaLabel}}</a> | ||||
| 			<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton cfa-button">{{cfaLabel}}</a> | ||||
|  | ||||
| 			{{#learnMoreUrl}} | ||||
| 				<a id="learnMore-{{name}}" href="{{learnMoreUrl}}" class="button-link btn-white learnMoreButton cfa-button">Learn more</a> | ||||
| 			{{/learnMoreUrl}} | ||||
| 		</p> | ||||
|  | ||||
| 		{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}} | ||||
|   | ||||
| @@ -1,23 +1,91 @@ | ||||
| <div id="plans-section" class="env-{{env}}"> | ||||
| 	<style> | ||||
| 		.toggle-container { | ||||
| 			display: flex; | ||||
| 			border: 2px solid black; | ||||
| 			border-radius: 100px; | ||||
| 			overflow: hidden; | ||||
| 			cursor: pointer; | ||||
| 			margin-top: 20px; | ||||
| 			max-width: 600px; | ||||
| 			margin-left: auto; | ||||
| 			margin-right: auto; | ||||
| 		} | ||||
|  | ||||
| 		.toggle-option { | ||||
| 			flex: 1; | ||||
| 			padding: 10px 20px; | ||||
| 			text-align: center; | ||||
| 			transition: background 0.3s, color 0.3s; | ||||
| 			user-select: none; | ||||
| 			white-space: nowrap; | ||||
| 		} | ||||
|  | ||||
| 		.active { | ||||
| 			background: black; | ||||
| 			color: white; | ||||
| 		} | ||||
|  | ||||
| 		.inactive { | ||||
| 			background: white; | ||||
| 			color: black; | ||||
| 		} | ||||
|  | ||||
| 		@media (max-width: 480px) { | ||||
| 			.toggle-container { | ||||
| 				flex-direction: column; | ||||
| 				width: 100%; | ||||
| 				border-radius: 10px; | ||||
| 			} | ||||
| 		} | ||||
| 	</style> | ||||
|  | ||||
| 	<div class="container"> | ||||
| 		<div class="row"> | ||||
| 			<div class="col-12 title-box"> | ||||
| 				<h1 translate class="text-center"> | ||||
| 					Joplin Cloud <span class="frame-bg frame-bg-yellow">plans</span> | ||||
| 					Our synchronisation and sharing <span class="frame-bg frame-bg-yellow">solutions</span> | ||||
| 				</h1> | ||||
| 				<p translate class="text-center sub-title"> | ||||
| 					<a href="https://joplincloud.com">Joplin Cloud</a> allows you to synchronise your notes across devices. It also lets you publish notes, and collaborate on notebooks with your friends, family or colleagues. | ||||
| 					Synchronise and share your notes with our range of plans. | ||||
| 				</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="toggle-container" id="toggle"> | ||||
| 			<div class="toggle-option active toggle-button-managed">Managed hosting</div> | ||||
| 			<div class="toggle-option inactive toggle-button-self">Self-hosting</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<noscript> | ||||
| 			<div class="alert alert-danger alert-env-dev" role="alert" style='text-align: center; margin-top: 10px;'> | ||||
| 				To use this page please enable JavaScript! | ||||
| 			</div> | ||||
| 		</noscript> | ||||
|  | ||||
| 		<div style="display: flex; justify-content: center; margin-top: 1.2em">	 | ||||
| 		<div class="row hosting-type-managed"> | ||||
| 			<div class="col-12 title-box"> | ||||
| 				<h1 translate class="text-center"> | ||||
| 					Joplin Cloud | ||||
| 				</h1> | ||||
| 				<p translate class="text-center sub-title"> | ||||
| 					<a href="https://joplincloud.com">Joplin Cloud</a> allows you to synchronise your notes across devices. It also lets you publish notes, and collaborate on notebooks with your friends, family or colleagues. | ||||
| 				</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="row hosting-type-self"> | ||||
| 			<div class="col-12 title-box"> | ||||
| 				<h1 translate class="text-center"> | ||||
| 					Joplin Server Business | ||||
| 				</h1> | ||||
| 				<p translate class="text-center sub-title"> | ||||
| 					Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business. | ||||
| 				</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div style="display: flex; justify-content: center; margin-top: 1.2em" class="hosting-type-managed">	 | ||||
| 			<div class="form-check form-check-inline"> | ||||
| 				<input id="pay-monthly-radio" class="form-check-input" type="radio" name="pay-radio" checked value="monthly"> | ||||
| 				<label translate style="font-weight: bold" class="form-check-label" for="pay-monthly-radio"> | ||||
| @@ -46,7 +114,11 @@ | ||||
| 				{{> plan}} | ||||
| 			{{/plans.teams}} | ||||
|  | ||||
| 			<p translate class="joplin-cloud-login-info">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p> | ||||
| 			{{#plans.joplinServerBusiness}} | ||||
| 				{{> plan}} | ||||
| 			{{/plans.joplinServerBusiness}} | ||||
|  | ||||
| 			<p translate class="joplin-cloud-login-info hosting-type-managed">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="row"> | ||||
| @@ -148,4 +220,30 @@ | ||||
| 			}); | ||||
| 		}); | ||||
| 	</script> | ||||
|  | ||||
|  | ||||
| 	<script> | ||||
| 		const setHostingType = (type) => { | ||||
| 			const other = type === 'managed' ? 'self' : 'managed'; | ||||
| 			$('.toggle-button-' + type).addClass('active'); | ||||
| 			$('.toggle-button-' + type).removeClass('inactive'); | ||||
| 			$('.toggle-button-' + other).addClass('inactive'); | ||||
| 			$('.toggle-button-' + other).removeClass('active'); | ||||
|  | ||||
| 			$('.hosting-type-' + type).show(); | ||||
| 			$('.hosting-type-' + other).hide(); | ||||
| 		} | ||||
|  | ||||
| 		$('.toggle-button-managed').click((event) => { | ||||
| 			event.preventDefault(); | ||||
| 			setHostingType('managed');			 | ||||
| 		}); | ||||
|  | ||||
| 		$('.toggle-button-self').click((event) => { | ||||
| 			event.preventDefault(); | ||||
| 			setHostingType('self'); | ||||
| 		}); | ||||
|  | ||||
| 		setHostingType('managed'); | ||||
| 	</script> | ||||
| </div> | ||||
|   | ||||
| @@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read | ||||
| # Sponsors | ||||
|  | ||||
| <!-- SPONSORS-ORG --> | ||||
| <a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://www.reddit.com/r/tiktokRise/"><img title="Tiktok Rise" width="256" src="https://joplinapp.org/images/sponsors/TiktokRise.jpg" alt="Tiktok Rise"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> | ||||
| <a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> | ||||
| <!-- SPONSORS-ORG --> | ||||
|  | ||||
| * * * | ||||
|   | ||||
| @@ -38,6 +38,7 @@ | ||||
|     "linter-precommit": "eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx", | ||||
|     "linter": "eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx", | ||||
|     "packageJsonLint": "node ./packages/tools/packageJsonLint.js", | ||||
|     "syncFuzzer": "node ./packages/tools/fuzzer/sync-fuzzer.js", | ||||
|     "postinstall": "husky && gulp build", | ||||
|     "postPreReleasesToForum": "node ./packages/tools/postPreReleasesToForum", | ||||
|     "publishAll": "git pull && yarn buildParallel && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll", | ||||
| @@ -83,7 +84,7 @@ | ||||
|     "gulp": "4.0.2", | ||||
|     "husky": "9.1.7", | ||||
|     "lerna": "3.22.1", | ||||
|     "lint-staged": "15.4.3", | ||||
|     "lint-staged": "15.5.0", | ||||
|     "madge": "8.0.0", | ||||
|     "npm-package-json-lint": "8.0.0", | ||||
|     "typescript": "5.4.5" | ||||
|   | ||||
| @@ -419,6 +419,11 @@ class Application extends BaseApplication { | ||||
|  | ||||
| 		this.initRedux(); | ||||
|  | ||||
| 		// Since the settings need to be loaded before the store is created, it will never | ||||
| 		// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be | ||||
| 		// initialised. So we manually call dispatchUpdateAll() to force an update. | ||||
| 		Setting.dispatchUpdateAll(); | ||||
|  | ||||
| 		if (!shim.sharpEnabled()) this.logger().warn('Sharp is disabled - certain image-related features will not be available'); | ||||
|  | ||||
| 		initializeCommandService(this.store(), Setting.value('env') === Env.Dev); | ||||
| @@ -461,11 +466,6 @@ class Application extends BaseApplication { | ||||
| 			this.gui_.setLogger(this.logger()); | ||||
| 			await this.gui_.start(); | ||||
|  | ||||
| 			// Since the settings need to be loaded before the store is created, it will never | ||||
| 			// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be | ||||
| 			// initialised. So we manually call dispatchUpdateAll() to force an update. | ||||
| 			Setting.dispatchUpdateAll(); | ||||
|  | ||||
| 			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 			await refreshFolders((action: any) => this.store().dispatch(action), ''); | ||||
|  | ||||
|   | ||||
| @@ -26,6 +26,7 @@ class Command extends BaseCommand { | ||||
| 			['-v, --verbose', 'More verbose output for the `target-status` command'], | ||||
| 			['-o, --output <directory>', 'Output directory'], | ||||
| 			['--retry-failed-items', 'Applies to `decrypt` command - retries decrypting items that previously could not be decrypted.'], | ||||
| 			['-f, --force', 'Do not ask for input on failure'], | ||||
| 		]; | ||||
| 	} | ||||
|  | ||||
| @@ -67,7 +68,7 @@ class Command extends BaseCommand { | ||||
| 					this.stdout(line.join('\n')); | ||||
| 					break; | ||||
| 				} catch (error) { | ||||
| 					if (error.code === 'masterKeyNotLoaded') { | ||||
| 					if (error.code === 'masterKeyNotLoaded' && !args.options.force) { | ||||
| 						const ok = await askForMasterKey(error); | ||||
| 						if (!ok) return; | ||||
| 						continue; | ||||
|   | ||||
| @@ -17,6 +17,7 @@ import { pathExists, writeFile } from 'fs-extra'; | ||||
| import { checkIfLoginWasSuccessful, generateApplicationConfirmUrl } from '@joplin/lib/services/joplinCloudUtils'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { uuidgen } from '@joplin/lib/uuid'; | ||||
| import ShareService from '@joplin/lib/services/share/ShareService'; | ||||
|  | ||||
| const logger = Logger.create('command-sync'); | ||||
|  | ||||
| @@ -230,6 +231,10 @@ class Command extends BaseCommand { | ||||
| 				return cleanUp(); | ||||
| 			} | ||||
|  | ||||
| 			// Refresh share invitations -- if running without a GUI, some of the | ||||
| 			// maintenance tasks may otherwise be skipped. | ||||
| 			await ShareService.instance().maintenance(); | ||||
|  | ||||
| 			this.stdout(_('Starting synchronisation...')); | ||||
|  | ||||
| 			const contextKey = `sync.${this.syncTargetId_}.context`; | ||||
|   | ||||
							
								
								
									
										1
									
								
								packages/app-cli/tests/html_to_md/comments_in_style.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <p><span style="/* Comment */ text-decoration: underline;">Test</span>. In the past, <span style="font-size: auto;/* Test! */">comments</span> in CSS have caused issues.</p> | ||||
							
								
								
									
										1
									
								
								packages/app-cli/tests/html_to_md/comments_in_style.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <ins>Test</ins>. In the past, comments in CSS have caused issues. | ||||
| @@ -343,6 +343,14 @@ export default class ElectronAppWrapper { | ||||
| 			}, 1000); | ||||
| 		} | ||||
|  | ||||
| 		const sendWindowFocused = (focusedWebContents: WebContents) => { | ||||
| 			const joplinId = this.windowIdFromWebContents(focusedWebContents); | ||||
|  | ||||
| 			if (joplinId !== null) { | ||||
| 				this.win_.webContents.send('window-focused', joplinId); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		const addWindowEventHandlers = (webContents: WebContents) => { | ||||
| 			// will-frame-navigate is fired by clicking on a link within the BrowserWindow. | ||||
| 			webContents.on('will-frame-navigate', event => { | ||||
| @@ -376,13 +384,10 @@ export default class ElectronAppWrapper { | ||||
| 				addWindowEventHandlers(event.webContents); | ||||
| 			}); | ||||
|  | ||||
| 			webContents.on('focus', () => { | ||||
| 				const joplinId = this.windowIdFromWebContents(webContents); | ||||
|  | ||||
| 				if (joplinId !== null) { | ||||
| 					this.win_.webContents.send('window-focused', joplinId); | ||||
| 				} | ||||
| 			}); | ||||
| 			const onFocus = () => { | ||||
| 				sendWindowFocused(webContents); | ||||
| 			}; | ||||
| 			webContents.on('focus', onFocus); | ||||
| 		}; | ||||
| 		addWindowEventHandlers(this.win_.webContents); | ||||
|  | ||||
| @@ -454,6 +459,10 @@ export default class ElectronAppWrapper { | ||||
| 					this.win_.close(); | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| 			if (window.isFocused()) { | ||||
| 				sendWindowFocused(window.webContents); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
|   | ||||
| @@ -1,9 +1,12 @@ | ||||
| import { filename, toForwardSlashes } from '@joplin/utils/path'; | ||||
| import * as esbuild from 'esbuild'; | ||||
| import { existsSync } from 'fs'; | ||||
| import { existsSync, readFileSync } from 'fs'; | ||||
| import { writeFile } from 'fs/promises'; | ||||
| import { dirname, join, relative } from 'path'; | ||||
|  | ||||
| const baseDir = dirname(__dirname); | ||||
| const baseNodeModules = join(baseDir, 'node_modules'); | ||||
|  | ||||
| // Note: Roughly based on js-draw's use of esbuild: | ||||
| // https://github.com/personalizedrefrigerator/js-draw/blob/6fe6d6821402a08a8d17f15a8f48d95e5d7b084f/packages/build-tool/src/BundledFile.ts#L64 | ||||
| const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSizeStats: boolean) => { | ||||
| @@ -28,8 +31,6 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize | ||||
| 				name: 'joplin--relative-imports-for-externals', | ||||
| 				setup: build => { | ||||
| 					const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/; | ||||
| 					const baseDir = dirname(__dirname); | ||||
| 					const baseNodeModules = join(baseDir, 'node_modules'); | ||||
| 					build.onResolve({ filter: externalRegex }, args => { | ||||
| 						// Electron packages don't need relative requires | ||||
| 						if (args.path === 'electron' || args.path.startsWith('electron/')) { | ||||
| @@ -66,8 +67,6 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize | ||||
| 				// Rewrite imports to prefer .js files to .ts. Otherwise, certain files are duplicated in the final bundle | ||||
| 				name: 'joplin--prefer-js-imports', | ||||
| 				setup: build => { | ||||
| 					const baseDir = dirname(__dirname); | ||||
| 					const baseNodeModules = join(baseDir, 'node_modules'); | ||||
| 					// Rewrite all relative imports | ||||
| 					build.onResolve({ filter: /^\./ }, args => { | ||||
| 						try { | ||||
| @@ -90,6 +89,31 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize | ||||
| 					}); | ||||
| 				}, | ||||
| 			}, | ||||
| 			{ | ||||
| 				name: 'joplin--smaller-source-map-size', | ||||
| 				setup: build => { | ||||
| 					// Exclude dependencies from node_modules. This significantly reduces the size of the | ||||
| 					// source map, improving startup performance. | ||||
| 					// | ||||
| 					// See https://github.com/evanw/esbuild/issues/1685#issuecomment-944916409 | ||||
| 					// and https://github.com/evanw/esbuild/issues/4130 | ||||
| 					const emptyMapData = Buffer.from( | ||||
| 						JSON.stringify({ version: 3, sources: [null], mappings: 'AAAA' }), | ||||
| 						'utf-8', | ||||
| 					).toString('base64'); | ||||
| 					const emptyMapUrl = `data:application/json;base64,${emptyMapData}`; | ||||
|  | ||||
| 					build.onLoad({ filter: /node_modules.*js$/ }, args => { | ||||
| 						return { | ||||
| 							contents: [ | ||||
| 								readFileSync(args.path, 'utf8'), | ||||
| 								`//# sourceMappingURL=${emptyMapUrl}`, | ||||
| 							].join('\n'), | ||||
| 							loader: 'default', | ||||
| 						}; | ||||
| 					}); | ||||
| 				}, | ||||
| 			}, | ||||
| 		], | ||||
| 	}); | ||||
| }; | ||||
|   | ||||
| @@ -60,6 +60,7 @@ const useCss = (editorTheme: Theme) => { | ||||
| 			body, html { | ||||
| 				padding: 0; | ||||
| 				margin: 0; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
|  | ||||
| 			/* Hide the scrollbar. See scrollbar accessibility concerns | ||||
|   | ||||
| @@ -533,7 +533,7 @@ | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements; | ||||
| 				CURRENT_PROJECT_VERSION = 141; | ||||
| 				CURRENT_PROJECT_VERSION = 142; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Joplin/Info.plist; | ||||
| @@ -568,7 +568,7 @@ | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements; | ||||
| 				CURRENT_PROJECT_VERSION = 141; | ||||
| 				CURRENT_PROJECT_VERSION = 142; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				INFOPLIST_FILE = Joplin/Info.plist; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.6; | ||||
| @@ -767,7 +767,7 @@ | ||||
| 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||
| 				CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				CURRENT_PROJECT_VERSION = 141; | ||||
| 				CURRENT_PROJECT_VERSION = 142; | ||||
| 				DEBUG_INFORMATION_FORMAT = dwarf; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu11; | ||||
| @@ -810,7 +810,7 @@ | ||||
| 				CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				COPY_PHASE_STRIP = NO; | ||||
| 				CURRENT_PROJECT_VERSION = 141; | ||||
| 				CURRENT_PROJECT_VERSION = 142; | ||||
| 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; | ||||
| 				DEVELOPMENT_TEAM = A9BXAFS6CT; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu11; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| module.exports = { | ||||
| hash:"e857ce4f63c45b5c1d25eb9a76c2127d", files: { | ||||
| hash:"39ce682c4ff5dd85d571d0e99718648f", files: { | ||||
| 'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' }, | ||||
| 'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' }, | ||||
| 'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' }, | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| module.exports = {"hash":"e857ce4f63c45b5c1d25eb9a76c2127d","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]} | ||||
| module.exports = {"hash":"39ce682c4ff5dd85d571d0e99718648f","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]} | ||||
| @@ -1353,7 +1353,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState> | ||||
| 					isOpen={this.props.showSideMenu} | ||||
| 					disableGestures={disableSideMenuGestures} | ||||
| 				> | ||||
| 					<StatusBar barStyle={statusBarStyle} /> | ||||
| 					<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: '100%' }}> | ||||
| 						<SafeAreaView style={{ flex: 0, backgroundColor: theme.backgroundColor2 }}/> | ||||
| 						<SafeAreaView style={{ flex: 1 }}> | ||||
| @@ -1362,11 +1361,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState> | ||||
| 							</View> | ||||
| 							{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */} | ||||
| 							<DropdownAlert alert={(func: any) => (this.dropdownAlert_ = func)} /> | ||||
| 							{ !shouldShowMainContent && <BiometricPopup | ||||
| 								dispatch={this.props.dispatch} | ||||
| 								themeId={this.props.themeId} | ||||
| 								sensorInfo={this.state.sensorInfo} | ||||
| 							/> } | ||||
| 						</SafeAreaView> | ||||
| 					</View> | ||||
| 				</SideMenu> | ||||
| @@ -1416,12 +1410,21 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState> | ||||
| 					}, | ||||
| 				}}> | ||||
| 					<DialogManager themeId={this.props.themeId}> | ||||
| 						<StatusBar barStyle={statusBarStyle} /> | ||||
| 						<MenuProvider | ||||
| 							style={{ flex: 1 }} | ||||
| 							closeButtonLabel={_('Dismiss')} | ||||
| 						> | ||||
| 							<FocusControl.MainAppContent style={{ flex: 1 }}> | ||||
| 								{mainContent} | ||||
| 								{shouldShowMainContent ? mainContent : ( | ||||
| 									<SafeAreaView> | ||||
| 										<BiometricPopup | ||||
| 											dispatch={this.props.dispatch} | ||||
| 											themeId={this.props.themeId} | ||||
| 											sensorInfo={this.state.sensorInfo} | ||||
| 										/> | ||||
| 									</SafeAreaView> | ||||
| 								)} | ||||
| 							</FocusControl.MainAppContent> | ||||
| 						</MenuProvider> | ||||
| 					</DialogManager> | ||||
|   | ||||
| @@ -8,9 +8,9 @@ import { chdir, cwd } from 'process'; | ||||
| import { execCommand } from '@joplin/utils'; | ||||
| import { glob } from 'glob'; | ||||
| import readRepositoryJson from './utils/readRepositoryJson'; | ||||
| import waitForCliInput from './utils/waitForCliInput'; | ||||
| import getPathToPatchFileFor from './utils/getPathToPatchFileFor'; | ||||
| import getCurrentCommitHash from './utils/getCurrentCommitHash'; | ||||
| import { waitForCliInput } from '@joplin/utils/cli'; | ||||
|  | ||||
| interface Options { | ||||
| 	beforeInstall: (buildDir: string, pluginName: string)=> Promise<void>; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { execCommand } from '@joplin/utils'; | ||||
| import waitForCliInput from '../utils/waitForCliInput'; | ||||
| import { copy } from 'fs-extra'; | ||||
| import { join } from 'path'; | ||||
| import { waitForCliInput } from '@joplin/utils/cli'; | ||||
| import buildDefaultPlugins from '../buildDefaultPlugins'; | ||||
| import getPathToPatchFileFor from '../utils/getPathToPatchFileFor'; | ||||
|  | ||||
|   | ||||
| @@ -98,7 +98,7 @@ export default class ClipperServer { | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public async findAvailablePort() { | ||||
| 	public async findAvailablePort(): Promise<number> { | ||||
| 		const tcpPortUsed = require('tcp-port-used'); | ||||
|  | ||||
| 		let state = null; | ||||
|   | ||||
| @@ -1,13 +1,21 @@ | ||||
|  | ||||
| const testPathIgnorePatterns = [ | ||||
| 	'<rootDir>/node_modules/', | ||||
| 	'<rootDir>/rnInjectedJs/', | ||||
| 	'<rootDir>/vendor/', | ||||
| ]; | ||||
|  | ||||
| if (!process.env.IS_CONTINUOUS_INTEGRATION) { | ||||
| 	// We don't require all developers to have Rust to run the project, so we skip this test if not running in CI | ||||
| 	testPathIgnorePatterns.push('<rootDir>/services/interop/InteropService_Importer_OneNote.*'); | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
| 	testMatch: [ | ||||
| 		'**/*.test.js', | ||||
| 	], | ||||
|  | ||||
| 	testPathIgnorePatterns: [ | ||||
| 		'<rootDir>/node_modules/', | ||||
| 		'<rootDir>/rnInjectedJs/', | ||||
| 		'<rootDir>/vendor/', | ||||
| 	], | ||||
| 	testPathIgnorePatterns: testPathIgnorePatterns, | ||||
|  | ||||
| 	testEnvironment: 'node', | ||||
|  | ||||
|   | ||||
| @@ -16,6 +16,7 @@ export enum MarkdownTableJustify { | ||||
| export interface MarkdownTableHeader { | ||||
| 	name: string; | ||||
| 	label: string; | ||||
| 	labelUrl?: string; | ||||
| 	filter?: (content: string)=> string; | ||||
| 	disableEscape?: boolean; | ||||
| 	disableHtmlEscape?: boolean; | ||||
| @@ -159,7 +160,11 @@ const markdownUtils = { | ||||
| 		const lineMd = []; | ||||
| 		for (let i = 0; i < headers.length; i++) { | ||||
| 			const h = headers[i]; | ||||
| 			headersMd.push(stringPadding(h.label, minCellWidth, ' ', stringPadding.RIGHT)); | ||||
| 			let label = h.label; | ||||
| 			if (h.labelUrl) { | ||||
| 				label = `[${h.label}](${h.labelUrl})`; | ||||
| 			} | ||||
| 			headersMd.push(stringPadding(label, minCellWidth, ' ', stringPadding.RIGHT)); | ||||
|  | ||||
| 			const justify = h.justify ? h.justify : MarkdownTableJustify.Left; | ||||
|  | ||||
|   | ||||
| @@ -357,7 +357,7 @@ export default class Folder extends BaseItem { | ||||
|  | ||||
| 		if (options && options.includeConflictFolder) { | ||||
| 			const conflictCount = await Note.conflictedCount(); | ||||
| 			if (conflictCount) output.push(this.conflictFolder()); | ||||
| 			if (conflictCount) output.unshift(this.conflictFolder()); | ||||
| 		} | ||||
|  | ||||
| 		return output; | ||||
|   | ||||
| @@ -22,9 +22,7 @@ const expectWithInstructions = <T>(value: T) => { | ||||
| 	return expect(value, instructionMessage); | ||||
| }; | ||||
|  | ||||
| // We don't require all developers to have Rust to run the project, so we skip this test if not running in CI | ||||
| const skipIfNotCI = process.env.IS_CONTINUOUS_INTEGRATION ? it : it.skip; | ||||
|  | ||||
| // This file is ignored if not running in CI. Look at onenote-converter/README.md and jest.config.js for more information | ||||
| describe('InteropService_Importer_OneNote', () => { | ||||
| 	let tempDir: string; | ||||
| 	async function importNote(path: string) { | ||||
| @@ -52,7 +50,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 	afterEach(async () => { | ||||
| 		await remove(tempDir); | ||||
| 	}); | ||||
| 	skipIfNotCI('should import a simple OneNote notebook', async () => { | ||||
| 	it('should import a simple OneNote notebook', async () => { | ||||
| 		const notes = await importNote(`${supportDir}/onenote/simple_notebook.zip`); | ||||
| 		const folders = await Folder.all(); | ||||
|  | ||||
| @@ -69,7 +67,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		expectWithInstructions(mainNote.body).toMatchSnapshot(mainNote.title); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should preserve indentation of subpages in Section page', async () => { | ||||
| 	it('should preserve indentation of subpages in Section page', async () => { | ||||
| 		const notes = await importNote(`${supportDir}/onenote/subpages.zip`); | ||||
|  | ||||
| 		const sectionPage = notes.find(n => n.title === 'Section'); | ||||
| @@ -89,7 +87,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		expectWithInstructions(menuLines[7].trim()).toBe(`<li class="l2"><a href=":/${pageTwoB.id}" target="content" title="Page 2-b">${pageTwoB.title}</a>`); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should created subsections', async () => { | ||||
| 	it('should created subsections', async () => { | ||||
| 		const notes = await importNote(`${supportDir}/onenote/subsections.zip`); | ||||
| 		const folders = await Folder.all(); | ||||
|  | ||||
| @@ -107,7 +105,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		expectWithInstructions(notesFromParentSection.length).toBe(2); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should expect notes to be rendered the same', async () => { | ||||
| 	it('should expect notes to be rendered the same', async () => { | ||||
| 		let idx = 0; | ||||
| 		const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++)); | ||||
| 		const notes = await importNote(`${supportDir}/onenote/complex_notes.zip`); | ||||
| @@ -124,7 +122,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		BaseModel.setIdGenerator(originalIdGenerator); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should render the proper tree for notebook with group sections', async () => { | ||||
| 	it('should render the proper tree for notebook with group sections', async () => { | ||||
| 		const notes = await importNote(`${supportDir}/onenote/group_sections.zip`); | ||||
| 		const folders = await Folder.all(); | ||||
|  | ||||
| @@ -152,7 +150,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		expectWithInstructions(notes.filter(n => n.parent_id === sectionD1.id).length).toBe(1); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI.each([ | ||||
| 	it.each([ | ||||
| 		'svg_with_text_and_style.html', | ||||
| 		'many_svgs.html', | ||||
| 	])('should extract svgs', async (filename: string) => { | ||||
| @@ -179,7 +177,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		expectWithInstructions(importer.extractSvgs(content, titleGenerator())).toMatchSnapshot(); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should ignore broken characters at the start of paragraph', async () => { | ||||
| 	it('should ignore broken characters at the start of paragraph', async () => { | ||||
| 		let idx = 0; | ||||
| 		const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++)); | ||||
| 		const notes = await importNote(`${supportDir}/onenote/bug_broken_character.zip`); | ||||
| @@ -189,7 +187,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		BaseModel.setIdGenerator(originalIdGenerator); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should remove hyperlink from title', async () => { | ||||
| 	it('should remove hyperlink from title', async () => { | ||||
| 		let idx = 0; | ||||
| 		const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++)); | ||||
| 		const notes = await importNote(`${supportDir}/onenote/remove_hyperlink_on_title.zip`); | ||||
| @@ -200,7 +198,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		BaseModel.setIdGenerator(originalIdGenerator); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should group link parts even if they have different css styles', async () => { | ||||
| 	it('should group link parts even if they have different css styles', async () => { | ||||
| 		const notes = await importNote(`${supportDir}/onenote/remove_hyperlink_on_title.zip`); | ||||
|  | ||||
| 		const noteToTest = notes.find(n => n.title === 'Tips from a Pro Using Trees for Dramatic Landscape Photography'); | ||||
| @@ -209,7 +207,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		expectWithInstructions(noteToTest.body.includes('<a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/风景.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography§ion-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a>')).toBe(true); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => { | ||||
| 	it('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => { | ||||
| 		let idx = 0; | ||||
| 		const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++)); | ||||
| 		const notes = await importNote(`${supportDir}/onenote/hyperlink_marker_as_first_character.zip`); | ||||
| @@ -220,7 +218,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		BaseModel.setIdGenerator(originalIdGenerator); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should be able to create notes from corrupted attachment', async () => { | ||||
| 	it('should be able to create notes from corrupted attachment', async () => { | ||||
| 		let idx = 0; | ||||
| 		const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++)); | ||||
| 		const notes = await withWarningSilenced(/OneNoteConverter:/, async () => importNote(`${supportDir}/onenote/corrupted_attachment.zip`)); | ||||
| @@ -233,7 +231,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		BaseModel.setIdGenerator(originalIdGenerator); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should render audio as links to resource', async () => { | ||||
| 	it('should render audio as links to resource', async () => { | ||||
| 		let idx = 0; | ||||
| 		const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++)); | ||||
| 		const notes = await importNote(`${supportDir}/onenote/note_with_audio_embedded.zip`); | ||||
| @@ -246,7 +244,7 @@ describe('InteropService_Importer_OneNote', () => { | ||||
| 		BaseModel.setIdGenerator(originalIdGenerator); | ||||
| 	}); | ||||
|  | ||||
| 	skipIfNotCI('should use default value for EntityGuid and InkBias if not found', async () => { | ||||
| 	it('should use default value for EntityGuid and InkBias if not found', async () => { | ||||
| 		let idx = 0; | ||||
| 		const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++)); | ||||
| 		const notes = await withWarningSilenced(/OneNoteConverter:/, async () => importNote(`${supportDir}/onenote/ink_bias_and_entity_guid.zip`)); | ||||
|   | ||||
| @@ -9,6 +9,7 @@ export enum PlanName { | ||||
| 	Basic = 'basic', | ||||
| 	Pro = 'pro', | ||||
| 	Teams = 'teams', | ||||
| 	JoplinServerBusiness = 'joplinServerBusiness', | ||||
| } | ||||
|  | ||||
| interface PlanFeature { | ||||
| @@ -17,6 +18,7 @@ interface PlanFeature { | ||||
| 	basic: boolean; | ||||
| 	pro: boolean; | ||||
| 	teams: boolean; | ||||
| 	joplinServerBusiness?: boolean; | ||||
| 	basicInfo?: string; | ||||
| 	proInfo?: string; | ||||
| 	teamsInfo?: string; | ||||
| @@ -25,11 +27,16 @@ interface PlanFeature { | ||||
| 	teamsInfoShort?: string; | ||||
| } | ||||
|  | ||||
| enum PlanHostingType { | ||||
| 	Managed = 'managed', | ||||
| 	Self = 'self', | ||||
| } | ||||
|  | ||||
| export interface Plan { | ||||
| 	name: string; | ||||
| 	title: string; | ||||
| 	priceMonthly: StripePublicConfigPrice; | ||||
| 	priceYearly: StripePublicConfigPrice; | ||||
| 	priceMonthly?: StripePublicConfigPrice; | ||||
| 	priceYearly?: StripePublicConfigPrice; | ||||
| 	featured: boolean; | ||||
| 	iconName: string; | ||||
| 	featuresOn: FeatureId[]; | ||||
| @@ -39,6 +46,8 @@ export interface Plan { | ||||
| 	cfaLabel: string; | ||||
| 	cfaUrl: string; | ||||
| 	footnote: string; | ||||
| 	learnMoreUrl?: string; | ||||
| 	hostingType: PlanHostingType; | ||||
| } | ||||
|  | ||||
| export enum PricePeriod { | ||||
| @@ -155,26 +164,29 @@ const features = (): Record<FeatureId, PlanFeature> => { | ||||
| 			basic: true, | ||||
| 			pro: true, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		sync: { | ||||
| 			title: _('Sync as many devices as you want'), | ||||
| 			basic: true, | ||||
| 			pro: true, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		clipper: { | ||||
| 			title: _('Web Clipper'), | ||||
| 			description: _('The [Web Clipper](%s) is a browser extension that allows you to save web pages and screenshots from your browser.', 'https://joplinapp.org/help/apps/clipper'), | ||||
| 			basic: true, | ||||
| 			pro: true, | ||||
| 			teams: true, | ||||
| 		}, | ||||
| 		// clipper: { | ||||
| 		// 	title: _('Web Clipper'), | ||||
| 		// 	description: _('The [Web Clipper](%s) is a browser extension that allows you to save web pages and screenshots from your browser.', 'https://joplinapp.org/help/apps/clipper'), | ||||
| 		// 	basic: false, | ||||
| 		// 	pro: false, | ||||
| 		// 	teams: false, | ||||
| 		// }, | ||||
| 		collaborate: { | ||||
| 			title: _('Collaborate on a notebook with others'), | ||||
| 			description: _('This allows another user to share a notebook with you, and you can then both collaborate on it. It does not however allow you to share a notebook with someone else, unless you have the feature "%s".', shareNotebookTitle), | ||||
| 			basic: true, | ||||
| 			pro: true, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		share: { | ||||
| 			title: shareNotebookTitle, | ||||
| @@ -182,6 +194,7 @@ const features = (): Record<FeatureId, PlanFeature> => { | ||||
| 			basic: false, | ||||
| 			pro: true, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		emailToNote: { | ||||
| 			title: _('Email to Note'), | ||||
| @@ -189,6 +202,7 @@ const features = (): Record<FeatureId, PlanFeature> => { | ||||
| 			basic: false, | ||||
| 			pro: true, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		customBanner: { | ||||
| 			title: _('Customise the note publishing banner'), | ||||
| @@ -196,6 +210,7 @@ const features = (): Record<FeatureId, PlanFeature> => { | ||||
| 			basic: false, | ||||
| 			pro: true, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		multiUsers: { | ||||
| 			title: _('Manage multiple users'), | ||||
| @@ -203,6 +218,7 @@ const features = (): Record<FeatureId, PlanFeature> => { | ||||
| 			basic: false, | ||||
| 			pro: false, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		consolidatedBilling: { | ||||
| 			title: _('Consolidated billing'), | ||||
| @@ -217,12 +233,28 @@ const features = (): Record<FeatureId, PlanFeature> => { | ||||
| 			basic: false, | ||||
| 			pro: false, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		prioritySupport: { | ||||
| 			title: _('Priority support'), | ||||
| 			basic: false, | ||||
| 			pro: false, | ||||
| 			teams: true, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		selfHosted: { | ||||
| 			title: _('Self-hosted'), | ||||
| 			basic: false, | ||||
| 			pro: false, | ||||
| 			teams: false, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 		sourceCodeAvailable: { | ||||
| 			title: _('Source code available'), | ||||
| 			basic: false, | ||||
| 			pro: false, | ||||
| 			teams: false, | ||||
| 			joplinServerBusiness: true, | ||||
| 		}, | ||||
| 	}; | ||||
| }; | ||||
| @@ -303,6 +335,11 @@ export const createFeatureTableMd = () => { | ||||
| 			name: 'teams', | ||||
| 			label: 'Teams', | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: 'joplinServerBusiness', | ||||
| 			label: 'Joplin Server Business', | ||||
| 			labelUrl: 'https://joplinapp.org/help/apps/joplin_server_business', | ||||
| 		}, | ||||
| 	]; | ||||
|  | ||||
| 	const rows: MarkdownTableRow[] = []; | ||||
| @@ -332,6 +369,7 @@ export const createFeatureTableMd = () => { | ||||
| 			basic: getCellInfo(PlanName.Basic, feature), | ||||
| 			pro: getCellInfo(PlanName.Pro, feature), | ||||
| 			teams: getCellInfo(PlanName.Teams, feature), | ||||
| 			joplinServerBusiness: getCellInfo(PlanName.JoplinServerBusiness, feature), | ||||
| 		}; | ||||
|  | ||||
| 		rows.push(row); | ||||
| @@ -362,6 +400,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla | ||||
| 			cfaLabel: _('Try it now'), | ||||
| 			cfaUrl: '', | ||||
| 			footnote: '', | ||||
| 			hostingType: PlanHostingType.Managed, | ||||
| 		}, | ||||
|  | ||||
| 		pro: { | ||||
| @@ -384,6 +423,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla | ||||
| 			cfaLabel: _('Try it now'), | ||||
| 			cfaUrl: '', | ||||
| 			footnote: '', | ||||
| 			hostingType: PlanHostingType.Managed, | ||||
| 		}, | ||||
|  | ||||
| 		teams: { | ||||
| @@ -406,6 +446,23 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla | ||||
| 			cfaLabel: _('Try it now'), | ||||
| 			cfaUrl: '', | ||||
| 			footnote: _('Per user. Minimum of 2 users.'), | ||||
| 			hostingType: PlanHostingType.Managed, | ||||
| 		}, | ||||
|  | ||||
| 		joplinServerBusiness: { | ||||
| 			name: 'joplinServerBusiness', | ||||
| 			title: _('Joplin Server Business'), | ||||
| 			featured: false, | ||||
| 			iconName: 'business-icon', | ||||
| 			featuresOn: getFeatureIdsByPlan(PlanName.JoplinServerBusiness, true), | ||||
| 			featuresOff: getFeatureIdsByPlan(PlanName.JoplinServerBusiness, false), | ||||
| 			featureLabelsOn: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, true), | ||||
| 			featureLabelsOff: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, false), | ||||
| 			cfaLabel: _('Get a quote'), | ||||
| 			cfaUrl: 'mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry', | ||||
| 			footnote: '', | ||||
| 			learnMoreUrl: 'https://joplinapp.org/help/apps/joplin_server_business', | ||||
| 			hostingType: PlanHostingType.Self, | ||||
| 		}, | ||||
| 	}; | ||||
| } | ||||
|   | ||||
| @@ -38,7 +38,7 @@ | ||||
|     "highlight.js": "11.11.1", | ||||
|     "html-entities": "1.4.0", | ||||
|     "json-stringify-safe": "5.0.1", | ||||
|     "katex": "0.16.21", | ||||
|     "katex": "0.16.22", | ||||
|     "markdown-it": "13.0.2", | ||||
|     "markdown-it-abbr": "1.0.4", | ||||
|     "markdown-it-anchor": "5.3.0", | ||||
|   | ||||
| @@ -39,7 +39,7 @@ | ||||
|     "html-entities": "1.4.0", | ||||
|     "jquery": "3.7.1", | ||||
|     "knex": "3.1.0", | ||||
|     "koa": "2.16.0", | ||||
|     "koa": "2.16.1", | ||||
|     "ldapts": "7.3.3", | ||||
|     "markdown-it": "13.0.2", | ||||
|     "mustache": "4.2.0", | ||||
| @@ -47,7 +47,7 @@ | ||||
|     "node-os-utils": "1.3.7", | ||||
|     "nodemailer": "6.10.0", | ||||
|     "nodemon": "3.1.9", | ||||
|     "pg": "8.13.3", | ||||
|     "pg": "8.14.1", | ||||
|     "pm2": "5.4.3", | ||||
|     "pretty-bytes": "5.6.0", | ||||
|     "prettycron": "0.10.0", | ||||
|   | ||||
| @@ -914,8 +914,14 @@ export default class ItemModel extends BaseModel<Item> { | ||||
| 			const share = await this.models().share().byItemId(item.id); | ||||
| 			if (!share) throw new Error(`Cannot find share associated with item ${item.id}`); | ||||
| 			const userShare = await this.models().shareUser().byShareAndUserId(share.id, userId); | ||||
| 			if (!userShare) return; | ||||
| 			await this.models().shareUser().delete(userShare.id); | ||||
|  | ||||
| 			if (userShare) { | ||||
| 				// Leave the share | ||||
| 				await this.models().shareUser().delete(userShare.id); | ||||
| 			} else if (share.owner_id === userId) { | ||||
| 				// Delete the share | ||||
| 				await this.models().share().delete(share.id); | ||||
| 			} | ||||
| 		} else { | ||||
| 			await this.delete(item.id); | ||||
| 		} | ||||
|   | ||||
							
								
								
									
										1
									
								
								packages/tools/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -4,3 +4,4 @@ patreon_oauth_token.txt | ||||
| *.po~ | ||||
| *.mo | ||||
| *.mo~ | ||||
| fuzzer/profiles-tmp/ | ||||
|   | ||||
| @@ -183,4 +183,7 @@ topagency | ||||
| esbuild | ||||
| mapbox | ||||
| outfile | ||||
|  | ||||
| fuzzer | ||||
| Freespinny | ||||
| BestEtf | ||||
| Etf | ||||
|   | ||||
							
								
								
									
										387
									
								
								packages/tools/fuzzer/ActionTracker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,387 @@ | ||||
| import { strict as assert } from 'assert'; | ||||
| import { ActionableClient, FolderData, FolderMetadata, FuzzContext, ItemId, NoteData, TreeItem, isFolder } from './types'; | ||||
| import type Client from './Client'; | ||||
|  | ||||
| interface ClientData { | ||||
| 	childIds: ItemId[]; | ||||
| 	// Shared folders belonging to the client | ||||
| 	sharedFolderIds: ItemId[]; | ||||
| } | ||||
|  | ||||
| class ActionTracker { | ||||
| 	private idToItem_: Map<ItemId, TreeItem> = new Map(); | ||||
| 	private tree_: Map<string, ClientData> = new Map(); | ||||
| 	public constructor(private readonly context_: FuzzContext) {} | ||||
|  | ||||
| 	private checkRep_() { | ||||
| 		const checkItem = (itemId: ItemId) => { | ||||
| 			assert.match(itemId, /^[a-zA-Z0-9]{32}$/, 'item IDs should be 32 character alphanumeric strings'); | ||||
|  | ||||
| 			const item = this.idToItem_.get(itemId); | ||||
| 			assert.ok(!!item, `should find item with ID ${itemId}`); | ||||
|  | ||||
| 			if (item.parentId) { | ||||
| 				const parent = this.idToItem_.get(item.parentId); | ||||
| 				assert.ok(parent, `should find parent (id: ${item.parentId})`); | ||||
|  | ||||
| 				assert.ok(isFolder(parent), 'parent should be a folder'); | ||||
| 				assert.ok(parent.childIds.includes(itemId), 'parent should include the current item in its children'); | ||||
| 			} | ||||
|  | ||||
| 			if (isFolder(item)) { | ||||
| 				for (const childId of item.childIds) { | ||||
| 					checkItem(childId); | ||||
| 				} | ||||
|  | ||||
| 				assert.equal( | ||||
| 					item.childIds.length, | ||||
| 					[...new Set(item.childIds)].length, | ||||
| 					'child IDs should be unique', | ||||
| 				); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		for (const clientData of this.tree_.values()) { | ||||
| 			for (const childId of clientData.childIds) { | ||||
| 				assert.ok(this.idToItem_.has(childId), `root item ${childId} should exist`); | ||||
|  | ||||
| 				const item = this.idToItem_.get(childId); | ||||
| 				assert.ok(!!item); | ||||
| 				assert.equal(item.parentId, '', `${childId} should not have a parent`); | ||||
|  | ||||
| 				checkItem(childId); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public track(client: { email: string }) { | ||||
| 		const clientId = client.email; | ||||
| 		this.tree_.set(clientId, { | ||||
| 			childIds: [], | ||||
| 			sharedFolderIds: [], | ||||
| 		}); | ||||
|  | ||||
| 		const getChildIds = (itemId: ItemId) => { | ||||
| 			const item = this.idToItem_.get(itemId); | ||||
| 			if (!item || !isFolder(item)) return []; | ||||
| 			return item.childIds; | ||||
| 		}; | ||||
| 		const updateChildren = (parentId: ItemId, updateFn: (oldChildren: ItemId[])=> ItemId[]) => { | ||||
| 			const parent = this.idToItem_.get(parentId); | ||||
| 			if (!parent) throw new Error(`Parent with ID ${parentId} not found.`); | ||||
| 			if (!isFolder(parent)) throw new Error(`Item ${parentId} is not a folder`); | ||||
|  | ||||
| 			this.idToItem_.set(parentId, { | ||||
| 				...parent, | ||||
| 				childIds: updateFn(parent.childIds), | ||||
| 			}); | ||||
| 		}; | ||||
| 		const addRootItem = (itemId: ItemId) => { | ||||
| 			const clientData = this.tree_.get(clientId); | ||||
| 			if (!clientData.childIds.includes(itemId)) { | ||||
| 				this.tree_.set(clientId, { | ||||
| 					...clientData, | ||||
| 					childIds: [...clientData.childIds, itemId], | ||||
| 				}); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		// Returns true iff the given item ID is now unused. | ||||
| 		const removeRootItem = (itemId: ItemId) => { | ||||
| 			const removeForClient = (clientId: string) => { | ||||
| 				const clientData = this.tree_.get(clientId); | ||||
| 				const childIds = clientData.childIds; | ||||
|  | ||||
| 				if (childIds.includes(itemId)) { | ||||
| 					const newChildIds = childIds.filter(otherId => otherId !== itemId); | ||||
| 					this.tree_.set(clientId, { | ||||
| 						...clientData, | ||||
| 						childIds: newChildIds, | ||||
| 					}); | ||||
| 					return true; | ||||
| 				} | ||||
|  | ||||
| 				return false; | ||||
| 			}; | ||||
|  | ||||
| 			const hasBeenCompletelyRemoved = () => { | ||||
| 				for (const clientData of this.tree_.values()) { | ||||
| 					if (clientData.childIds.includes(itemId)) { | ||||
| 						return false; | ||||
| 					} | ||||
| 				} | ||||
| 				return true; | ||||
| 			}; | ||||
|  | ||||
| 			const isOwnedByThis = this.tree_.get(clientId).sharedFolderIds.includes(itemId); | ||||
|  | ||||
| 			if (isOwnedByThis) { // Unshare | ||||
| 				let removed = false; | ||||
| 				for (const id of this.tree_.keys()) { | ||||
| 					const result = removeForClient(id); | ||||
| 					removed ||= result; | ||||
| 				} | ||||
|  | ||||
| 				const clientData = this.tree_.get(clientId); | ||||
| 				this.tree_.set(clientId, { | ||||
| 					...clientData, | ||||
| 					sharedFolderIds: clientData.sharedFolderIds.filter(id => id !== itemId), | ||||
| 				}); | ||||
|  | ||||
| 				// At this point, the item shouldn't be a child of any clients: | ||||
| 				assert.ok(hasBeenCompletelyRemoved(), 'item should be removed from all clients'); | ||||
| 				assert.ok(removed, 'should be a toplevel item'); | ||||
|  | ||||
| 				// The item is unshared and can be removed entirely | ||||
| 				return true; | ||||
| 			} else { | ||||
| 				// Otherwise, even if part of a share, removing the | ||||
| 				// notebook just leaves the share. | ||||
| 				const removed = removeForClient(clientId); | ||||
| 				assert.ok(removed, 'should be a toplevel item'); | ||||
|  | ||||
| 				if (hasBeenCompletelyRemoved()) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return false; | ||||
| 		}; | ||||
| 		const addChild = (parentId: ItemId, childId: ItemId) => { | ||||
| 			if (parentId) { | ||||
| 				updateChildren(parentId, (oldChildren) => { | ||||
| 					if (oldChildren.includes(childId)) return oldChildren; | ||||
| 					return [...oldChildren, childId]; | ||||
| 				}); | ||||
| 			} else { | ||||
| 				addRootItem(childId); | ||||
| 			} | ||||
| 		}; | ||||
| 		const removeChild = (parentId: ItemId, childId: ItemId) => { | ||||
| 			if (!parentId) { | ||||
| 				removeRootItem(childId); | ||||
| 			} else { | ||||
| 				updateChildren(parentId, (oldChildren) => { | ||||
| 					return oldChildren.filter(otherId => otherId !== childId); | ||||
| 				}); | ||||
| 			} | ||||
| 		}; | ||||
| 		const removeItemRecursive = (id: ItemId) => { | ||||
| 			const item = this.idToItem_.get(id); | ||||
| 			if (!item) throw new Error(`Item with ID ${id} not found.`); | ||||
|  | ||||
| 			if (item.parentId) { | ||||
| 				// The parent may already be removed | ||||
| 				if (this.idToItem_.has(item.parentId)) { | ||||
| 					removeChild(item.parentId, item.id); | ||||
| 				} | ||||
|  | ||||
| 				this.idToItem_.delete(id); | ||||
| 			} else { | ||||
| 				const idIsUnused = removeRootItem(item.id); | ||||
| 				if (idIsUnused) { | ||||
| 					this.idToItem_.delete(id); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (isFolder(item)) { | ||||
| 				for (const childId of item.childIds) { | ||||
| 					const child = this.idToItem_.get(childId); | ||||
| 					assert.equal(child?.parentId, id, `child ${childId} should have accurate parent ID`); | ||||
|  | ||||
| 					removeItemRecursive(childId); | ||||
| 				} | ||||
| 			} | ||||
| 		}; | ||||
| 		const mapItems = <T> (map: (item: TreeItem)=> T) => { | ||||
| 			const workList: ItemId[] = [...this.tree_.get(clientId).childIds]; | ||||
| 			const result: T[] = []; | ||||
|  | ||||
| 			while (workList.length > 0) { | ||||
| 				const id = workList.pop(); | ||||
| 				const item = this.idToItem_.get(id); | ||||
| 				if (!item) throw new Error(`Not found: ${id}`); | ||||
|  | ||||
| 				result.push(map(item)); | ||||
|  | ||||
| 				if (isFolder(item)) { | ||||
| 					for (const childId of item.childIds) { | ||||
| 						workList.push(childId); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return result; | ||||
| 		}; | ||||
|  | ||||
| 		const listFoldersDetailed = () => { | ||||
| 			return mapItems((item): FolderData => { | ||||
| 				return isFolder(item) ? item : null; | ||||
| 			}).filter(item => !!item); | ||||
| 		}; | ||||
|  | ||||
| 		const tracker: ActionableClient = { | ||||
| 			createNote: (data: NoteData) => { | ||||
| 				assert.ok(!!data.parentId, `note ${data.id} should have a parentId`); | ||||
| 				assert.ok(!this.idToItem_.has(data.id), `note ${data.id} should not yet exist`); | ||||
| 				this.idToItem_.set(data.id, { | ||||
| 					...data, | ||||
| 				}); | ||||
| 				addChild(data.parentId, data.id); | ||||
|  | ||||
| 				this.checkRep_(); | ||||
| 				return Promise.resolve(); | ||||
| 			}, | ||||
| 			updateNote: (data: NoteData) => { | ||||
| 				const oldItem = this.idToItem_.get(data.id); | ||||
| 				assert.ok(oldItem, `note ${data.id} should exist`); | ||||
| 				assert.ok(!!data.parentId, `note ${data.id} should have a parentId`); | ||||
|  | ||||
| 				removeChild(oldItem.parentId, data.id); | ||||
| 				this.idToItem_.set(data.id, { | ||||
| 					...data, | ||||
| 				}); | ||||
| 				addChild(data.parentId, data.id); | ||||
|  | ||||
| 				this.checkRep_(); | ||||
| 				return Promise.resolve(); | ||||
| 			}, | ||||
| 			createFolder: (data: FolderMetadata) => { | ||||
| 				this.idToItem_.set(data.id, { | ||||
| 					...data, | ||||
| 					parentId: data.parentId ?? '', | ||||
| 					childIds: getChildIds(data.id), | ||||
| 					isShareRoot: false, | ||||
| 				}); | ||||
| 				addChild(data.parentId, data.id); | ||||
|  | ||||
| 				this.checkRep_(); | ||||
| 				return Promise.resolve(); | ||||
| 			}, | ||||
| 			deleteFolder: (id: ItemId) => { | ||||
| 				this.checkRep_(); | ||||
|  | ||||
| 				const item = this.idToItem_.get(id); | ||||
| 				if (!item) throw new Error(`Not found ${id}`); | ||||
| 				if (!isFolder(item)) throw new Error(`Not a folder ${id}`); | ||||
|  | ||||
| 				removeItemRecursive(id); | ||||
|  | ||||
| 				this.checkRep_(); | ||||
| 				return Promise.resolve(); | ||||
| 			}, | ||||
| 			shareFolder: (id: ItemId, shareWith: Client) => { | ||||
| 				const shareWithChildIds = this.tree_.get(shareWith.email).childIds; | ||||
| 				if (shareWithChildIds.includes(id)) { | ||||
| 					throw new Error(`Folder ${id} already shared with ${shareWith.email}`); | ||||
| 				} | ||||
| 				assert.ok(this.idToItem_.has(id), 'should exist'); | ||||
|  | ||||
| 				const sharerClient = this.tree_.get(clientId); | ||||
| 				if (!sharerClient.sharedFolderIds.includes(id)) { | ||||
| 					this.tree_.set(clientId, { | ||||
| 						...sharerClient, | ||||
| 						sharedFolderIds: [...sharerClient.sharedFolderIds, id], | ||||
| 					}); | ||||
| 				} | ||||
|  | ||||
| 				this.tree_.set(shareWith.email, { | ||||
| 					...this.tree_.get(shareWith.email), | ||||
| 					childIds: [...shareWithChildIds, id], | ||||
| 				}); | ||||
|  | ||||
| 				this.idToItem_.set(id, { | ||||
| 					...this.idToItem_.get(id), | ||||
| 					isShareRoot: true, | ||||
| 				}); | ||||
|  | ||||
| 				this.checkRep_(); | ||||
| 				return Promise.resolve(); | ||||
| 			}, | ||||
| 			moveItem: (itemId, newParentId) => { | ||||
| 				const item = this.idToItem_.get(itemId); | ||||
| 				assert.ok(item, `item with ${itemId} should exist`); | ||||
|  | ||||
| 				if (newParentId) { | ||||
| 					const parent = this.idToItem_.get(newParentId); | ||||
| 					assert.ok(parent, `parent with ID ${newParentId} should exist`); | ||||
| 				} else { | ||||
| 					assert.equal(newParentId, '', 'parentId should be empty if a toplevel folder'); | ||||
| 				} | ||||
|  | ||||
| 				if (isFolder(item)) { | ||||
| 					assert.equal(item.isShareRoot, false, 'cannot move toplevel shared folders without first unsharing'); | ||||
| 				} | ||||
|  | ||||
| 				removeChild(item.parentId, itemId); | ||||
| 				addChild(newParentId, itemId); | ||||
| 				this.idToItem_.set(itemId, { | ||||
| 					...item, | ||||
| 					parentId: newParentId, | ||||
| 				}); | ||||
|  | ||||
| 				this.checkRep_(); | ||||
| 				return Promise.resolve(); | ||||
| 			}, | ||||
| 			sync: () => Promise.resolve(), | ||||
| 			listNotes: () => { | ||||
| 				const notes = mapItems(item => { | ||||
| 					return isFolder(item) ? null : item; | ||||
| 				}).filter(item => !!item); | ||||
|  | ||||
| 				this.checkRep_(); | ||||
| 				return Promise.resolve(notes); | ||||
| 			}, | ||||
| 			listFolders: () => { | ||||
| 				this.checkRep_(); | ||||
| 				const folderData = listFoldersDetailed().map(item => ({ | ||||
| 					id: item.id, | ||||
| 					title: item.title, | ||||
| 					parentId: item.parentId, | ||||
| 				})); | ||||
|  | ||||
| 				return Promise.resolve(folderData); | ||||
| 			}, | ||||
| 			allFolderDescendants: (parentId) => { | ||||
| 				this.checkRep_(); | ||||
|  | ||||
| 				const descendants: ItemId[] = []; | ||||
| 				const addDescendants = (id: ItemId) => { | ||||
| 					const item = this.idToItem_.get(id); | ||||
| 					assert.ok(isFolder(item), 'should be a folder'); | ||||
|  | ||||
| 					for (const id of item.childIds) { | ||||
| 						descendants.push(id); | ||||
|  | ||||
| 						const item = this.idToItem_.get(id); | ||||
| 						if (isFolder(item)) { | ||||
| 							addDescendants(item.id); | ||||
| 						} | ||||
| 					} | ||||
| 				}; | ||||
|  | ||||
| 				descendants.push(parentId); | ||||
| 				addDescendants(parentId); | ||||
|  | ||||
| 				return Promise.resolve(descendants); | ||||
| 			}, | ||||
| 			randomFolder: async (options) => { | ||||
| 				let folders = listFoldersDetailed(); | ||||
| 				if (options.filter) { | ||||
| 					folders = folders.filter(options.filter); | ||||
| 				} | ||||
|  | ||||
| 				const folderIndex = this.context_.randInt(0, folders.length); | ||||
| 				return folders.length ? folders[folderIndex] : null; | ||||
| 			}, | ||||
| 			randomNote: async () => { | ||||
| 				const notes = await tracker.listNotes(); | ||||
| 				const noteIndex = this.context_.randInt(0, notes.length); | ||||
| 				return notes.length ? notes[noteIndex] : null; | ||||
| 			}, | ||||
| 		}; | ||||
| 		return tracker; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export default ActionTracker; | ||||
							
								
								
									
										420
									
								
								packages/tools/fuzzer/Client.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,420 @@ | ||||
| import uuid, { createSecureRandom } from '@joplin/lib/uuid'; | ||||
| import { ActionableClient, FolderMetadata, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, UserData } from './types'; | ||||
| import { join } from 'path'; | ||||
| import { mkdir } from 'fs-extra'; | ||||
| import getStringProperty from './utils/getStringProperty'; | ||||
| import { strict as assert } from 'assert'; | ||||
| import ClipperServer from '@joplin/lib/ClipperServer'; | ||||
| import ActionTracker from './ActionTracker'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import execa = require('execa'); | ||||
| import { cliDirectory } from './constants'; | ||||
| import { commandToString } from '@joplin/utils'; | ||||
| import { quotePath } from '@joplin/utils/path'; | ||||
| import getNumberProperty from './utils/getNumberProperty'; | ||||
| import retryWithCount from './utils/retryWithCount'; | ||||
|  | ||||
| const logger = Logger.create('Client'); | ||||
|  | ||||
|  | ||||
| class Client implements ActionableClient { | ||||
| 	public readonly email: string; | ||||
|  | ||||
| 	public static async create(actionTracker: ActionTracker, context: FuzzContext) { | ||||
| 		const id = uuid.create(); | ||||
| 		const profileDirectory = join(context.baseDir, id); | ||||
| 		await mkdir(profileDirectory); | ||||
|  | ||||
| 		const email = `${id}@localhost`; | ||||
| 		const password = createSecureRandom(); | ||||
| 		const apiOutput = await context.execApi('POST', 'api/users', { | ||||
| 			email, | ||||
| 		}); | ||||
| 		const serverId = getStringProperty(apiOutput, 'id'); | ||||
|  | ||||
| 		// The password needs to be set *after* creating the user. | ||||
| 		const userRoute = `api/users/${encodeURIComponent(serverId)}`; | ||||
| 		await context.execApi('PATCH', userRoute, { | ||||
| 			email, | ||||
| 			password, | ||||
| 			email_confirmed: 1, | ||||
| 		}); | ||||
|  | ||||
| 		const closeAccount = async () => { | ||||
| 			await context.execApi('DELETE', userRoute, {}); | ||||
| 		}; | ||||
|  | ||||
| 		try { | ||||
| 			const userData = { | ||||
| 				email: getStringProperty(apiOutput, 'email'), | ||||
| 				password, | ||||
| 			}; | ||||
|  | ||||
| 			assert.equal(email, userData.email); | ||||
|  | ||||
| 			const apiToken = createSecureRandom().replace(/[-]/g, '_'); | ||||
| 			const apiPort = await ClipperServer.instance().findAvailablePort(); | ||||
|  | ||||
| 			const client = new Client( | ||||
| 				actionTracker.track({ email }), | ||||
| 				userData, | ||||
| 				profileDirectory, | ||||
| 				apiPort, | ||||
| 				apiToken, | ||||
| 				closeAccount, | ||||
| 			); | ||||
|  | ||||
| 			// Joplin Server sync | ||||
| 			await client.execCliCommand_('config', 'sync.target', '9'); | ||||
| 			await client.execCliCommand_('config', 'sync.9.path', context.serverUrl); | ||||
| 			await client.execCliCommand_('config', 'sync.9.username', userData.email); | ||||
| 			await client.execCliCommand_('config', 'sync.9.password', userData.password); | ||||
| 			await client.execCliCommand_('config', 'api.token', apiToken); | ||||
| 			await client.execCliCommand_('config', 'api.port', String(apiPort)); | ||||
|  | ||||
| 			const e2eePassword = createSecureRandom().replace(/^-/, '_'); | ||||
| 			await client.execCliCommand_('e2ee', 'enable', '--password', e2eePassword); | ||||
| 			logger.info('Created and configured client'); | ||||
|  | ||||
| 			// Run asynchronously -- the API server command doesn't exit until the server | ||||
| 			// is closed. | ||||
| 			void (async () => { | ||||
| 				try { | ||||
| 					await client.execCliCommand_('server', 'start'); | ||||
| 				} catch (error) { | ||||
| 					logger.info('API server exited'); | ||||
| 					logger.debug('API server exit status', error); | ||||
| 				} | ||||
| 			})(); | ||||
|  | ||||
| 			await client.sync(); | ||||
| 			return client; | ||||
| 		} catch (error) { | ||||
| 			await closeAccount(); | ||||
| 			throw error; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private constructor( | ||||
| 		private readonly tracker_: ActionableClient, | ||||
| 		userData: UserData, | ||||
| 		private readonly profileDirectory: string, | ||||
| 		private readonly apiPort_: number, | ||||
| 		private readonly apiToken_: string, | ||||
| 		private readonly cleanUp_: ()=> Promise<void>, | ||||
| 	) { | ||||
| 		this.email = userData.email; | ||||
| 	} | ||||
|  | ||||
| 	public async close() { | ||||
| 		await this.execCliCommand_('server', 'stop'); | ||||
| 		await this.cleanUp_(); | ||||
| 	} | ||||
|  | ||||
| 	private get cliCommandArguments() { | ||||
| 		return [ | ||||
| 			'start-no-build', | ||||
| 			'--profile', this.profileDirectory, | ||||
| 			'--env', 'dev', | ||||
| 		]; | ||||
| 	} | ||||
|  | ||||
| 	public getHelpText() { | ||||
| 		return [ | ||||
| 			`Client ${this.email}:`, | ||||
| 			`\tCommand: cd ${quotePath(cliDirectory)} && ${commandToString('yarn', this.cliCommandArguments)}`, | ||||
| 		].join('\n'); | ||||
| 	} | ||||
|  | ||||
| 	private async execCliCommand_(commandName: string, ...args: string[]) { | ||||
| 		assert.match(commandName, /^[a-z]/, 'Command name must start with a lowercase letter.'); | ||||
| 		const commandResult = await execa('yarn', [ | ||||
| 			...this.cliCommandArguments, | ||||
| 			commandName, | ||||
| 			...args, | ||||
| 		], { | ||||
| 			cwd: cliDirectory, | ||||
| 			// Connects /dev/null to stdin | ||||
| 			stdin: 'ignore', | ||||
| 		}); | ||||
| 		logger.debug('Ran command: ', commandResult.command, commandResult.exitCode); | ||||
| 		logger.debug('     Output: ', commandResult.stdout); | ||||
| 		return commandResult; | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member | ||||
| 	private async execApiCommand_(method: 'GET', route: string): Promise<Json>; | ||||
| 	// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member | ||||
| 	private async execApiCommand_(method: 'POST'|'PUT', route: string, data: Json): Promise<Json>; | ||||
| 	// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member | ||||
| 	private async execApiCommand_(method: HttpMethod, route: string, data: Json|null = null): Promise<Json> { | ||||
| 		route = route.replace(/^[/]/, ''); | ||||
| 		const url = new URL(`http://localhost:${this.apiPort_}/${route}`); | ||||
| 		url.searchParams.append('token', this.apiToken_); | ||||
|  | ||||
| 		const response = await fetch(url, { | ||||
| 			method, | ||||
| 			body: data ? JSON.stringify(data) : undefined, | ||||
| 		}); | ||||
|  | ||||
| 		if (!response.ok) { | ||||
| 			throw new Error(`Request to ${route} failed with error: ${await response.text()}`); | ||||
| 		} | ||||
|  | ||||
| 		return await response.json(); | ||||
| 	} | ||||
|  | ||||
| 	private async execPagedApiCommand_<Result>( | ||||
| 		method: 'GET', | ||||
| 		route: string, | ||||
| 		params: Record<string, string>, | ||||
| 		deserializeItem: (data: Json)=> Result, | ||||
| 	): Promise<Result[]> { | ||||
| 		const searchParams = new URLSearchParams(params); | ||||
|  | ||||
| 		const results: Result[] = []; | ||||
| 		let hasMore = true; | ||||
| 		for (let page = 1; hasMore; page++) { | ||||
| 			searchParams.set('page', String(page)); | ||||
| 			searchParams.set('limit', '10'); | ||||
| 			const response = await this.execApiCommand_( | ||||
| 				method, `${route}?${searchParams}`, | ||||
| 			); | ||||
| 			if ( | ||||
| 				typeof response !== 'object' | ||||
| 				|| !('has_more' in response) | ||||
| 				|| !('items' in response) | ||||
| 				|| !Array.isArray(response.items) | ||||
| 			) { | ||||
| 				throw new Error(`Invalid response: ${JSON.stringify(response)}`); | ||||
| 			} | ||||
| 			hasMore = !!response.has_more; | ||||
|  | ||||
| 			for (const item of response.items) { | ||||
| 				results.push(deserializeItem(item)); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return results; | ||||
| 	} | ||||
|  | ||||
| 	private async decrypt_() { | ||||
| 		// E2EE decryption can occasionally fail with "Master key is not loaded:". | ||||
| 		// Allow e2ee decryption to be retried: | ||||
| 		await retryWithCount(async () => { | ||||
| 			const result = await this.execCliCommand_('e2ee', 'decrypt', '--force'); | ||||
| 			if (!result.stdout.includes('Completed decryption.')) { | ||||
| 				throw new Error(`Decryption did not complete: ${result.stdout}`); | ||||
| 			} | ||||
| 		}, { | ||||
| 			count: 3, | ||||
| 			onFail: async (error)=>{ | ||||
| 				logger.warn('E2EE decryption failed:', error); | ||||
| 				logger.info('Syncing before retry...'); | ||||
| 				await this.execCliCommand_('sync'); | ||||
| 			}, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public async sync() { | ||||
| 		logger.info('Sync', this.email); | ||||
|  | ||||
| 		await this.tracker_.sync(); | ||||
|  | ||||
| 		const result = await this.execCliCommand_('sync'); | ||||
| 		if (result.stdout.match(/Last error:/i)) { | ||||
| 			throw new Error(`Sync failed: ${result.stdout}`); | ||||
| 		} | ||||
|  | ||||
| 		await this.decrypt_(); | ||||
| 	} | ||||
|  | ||||
| 	public async createFolder(folder: FolderMetadata) { | ||||
| 		logger.info('Create folder', folder.id, 'in', `${folder.parentId ?? 'root'}/${this.email}`); | ||||
| 		await this.tracker_.createFolder(folder); | ||||
|  | ||||
| 		await this.execApiCommand_('POST', '/folders', { | ||||
| 			id: folder.id, | ||||
| 			title: folder.title, | ||||
| 			parent_id: folder.parentId ?? '', | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	private async assertNoteMatchesState_(expected: NoteData) { | ||||
| 		assert.equal( | ||||
| 			(await this.execCliCommand_('cat', expected.id)).stdout, | ||||
| 			`${expected.title}\n\n${expected.body}`, | ||||
| 			'note should exist', | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	public async createNote(note: NoteData) { | ||||
| 		logger.info('Create note', note.id, 'in', `${note.parentId}/${this.email}`); | ||||
| 		await this.tracker_.createNote(note); | ||||
|  | ||||
| 		await this.execApiCommand_('POST', '/notes', { | ||||
| 			id: note.id, | ||||
| 			title: note.title, | ||||
| 			body: note.body, | ||||
| 			parent_id: note.parentId ?? '', | ||||
| 		}); | ||||
| 		await this.assertNoteMatchesState_(note); | ||||
| 	} | ||||
|  | ||||
| 	public async updateNote(note: NoteData) { | ||||
| 		logger.info('Update note', note.id, 'in', `${note.parentId}/${this.email}`); | ||||
| 		await this.tracker_.updateNote(note); | ||||
| 		await this.execApiCommand_('PUT', `/notes/${encodeURIComponent(note.id)}`, { | ||||
| 			title: note.title, | ||||
| 			body: note.body, | ||||
| 			parent_id: note.parentId ?? '', | ||||
| 		}); | ||||
| 		await this.assertNoteMatchesState_(note); | ||||
| 	} | ||||
|  | ||||
| 	public async deleteFolder(id: string) { | ||||
| 		logger.info('Delete folder', id, 'in', this.email); | ||||
| 		await this.tracker_.deleteFolder(id); | ||||
|  | ||||
| 		await this.execCliCommand_('rmbook', '--permanent', '--force', id); | ||||
| 	} | ||||
|  | ||||
| 	public async shareFolder(id: string, shareWith: Client) { | ||||
| 		await this.tracker_.shareFolder(id, shareWith); | ||||
|  | ||||
| 		logger.info('Share', id, 'with', shareWith.email); | ||||
| 		await this.execCliCommand_('share', 'add', id, shareWith.email); | ||||
| 		await this.sync(); | ||||
| 		await shareWith.sync(); | ||||
|  | ||||
| 		const shareWithIncoming = JSON.parse((await shareWith.execCliCommand_('share', 'list', '--json')).stdout); | ||||
| 		const pendingInvitations = shareWithIncoming.invitations.filter((invitation: unknown) => { | ||||
| 			if (typeof invitation !== 'object' || !('accepted' in invitation)) { | ||||
| 				throw new Error('Invalid invitation format'); | ||||
| 			} | ||||
| 			return !invitation.accepted; | ||||
| 		}); | ||||
| 		assert.deepEqual(pendingInvitations, [ | ||||
| 			{ | ||||
| 				accepted: false, | ||||
| 				waiting: true, | ||||
| 				rejected: false, | ||||
| 				folderId: id, | ||||
| 				fromUser: { | ||||
| 					email: this.email, | ||||
| 				}, | ||||
| 			}, | ||||
| 		], 'there should be a single incoming share from the expected user'); | ||||
|  | ||||
| 		await shareWith.execCliCommand_('share', 'accept', id); | ||||
| 	} | ||||
|  | ||||
| 	public async moveItem(itemId: ItemId, newParentId: ItemId) { | ||||
| 		logger.info('Move', itemId, 'to', newParentId); | ||||
| 		await this.tracker_.moveItem(itemId, newParentId); | ||||
| 		const movingToRoot = !newParentId; | ||||
| 		await this.execCliCommand_('mv', itemId, movingToRoot ? 'root' : newParentId); | ||||
| 	} | ||||
|  | ||||
| 	public async listNotes() { | ||||
| 		const params = { | ||||
| 			fields: 'id,parent_id,body,title,is_conflict,conflict_original_id', | ||||
| 			include_deleted: '1', | ||||
| 			include_conflicts: '1', | ||||
| 		}; | ||||
| 		return await this.execPagedApiCommand_( | ||||
| 			'GET', | ||||
| 			'/notes', | ||||
| 			params, | ||||
| 			item => ({ | ||||
| 				id: getStringProperty(item, 'id'), | ||||
| 				parentId: getNumberProperty(item, 'is_conflict') === 1 ? ( | ||||
| 					`[conflicts for ${getStringProperty(item, 'conflict_original_id')} in ${this.email}]` | ||||
| 				) : getStringProperty(item, 'parent_id'), | ||||
| 				title: getStringProperty(item, 'title'), | ||||
| 				body: getStringProperty(item, 'body'), | ||||
| 			}), | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	public async listFolders() { | ||||
| 		const params = { | ||||
| 			fields: 'id,parent_id,title', | ||||
| 			include_deleted: '1', | ||||
| 		}; | ||||
| 		return await this.execPagedApiCommand_( | ||||
| 			'GET', | ||||
| 			'/folders', | ||||
| 			params, | ||||
| 			item => ({ | ||||
| 				id: getStringProperty(item, 'id'), | ||||
| 				parentId: getStringProperty(item, 'parent_id'), | ||||
| 				title: getStringProperty(item, 'title'), | ||||
| 			}), | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	public async randomFolder(options: RandomFolderOptions) { | ||||
| 		return this.tracker_.randomFolder(options); | ||||
| 	} | ||||
|  | ||||
| 	public async allFolderDescendants(parentId: ItemId) { | ||||
| 		return this.tracker_.allFolderDescendants(parentId); | ||||
| 	} | ||||
|  | ||||
| 	public async randomNote() { | ||||
| 		return this.tracker_.randomNote(); | ||||
| 	} | ||||
|  | ||||
| 	public async checkState(_allClients: Client[]) { | ||||
| 		logger.info('Check state', this.email); | ||||
|  | ||||
| 		type ItemSlice = { id: string }; | ||||
| 		const compare = (a: ItemSlice, b: ItemSlice) => { | ||||
| 			if (a.id === b.id) return 0; | ||||
| 			return a.id < b.id ? -1 : 1; | ||||
| 		}; | ||||
|  | ||||
| 		const assertNoAdjacentEqualIds = (sortedById: ItemSlice[], assertionLabel: string) => { | ||||
| 			for (let i = 1; i < sortedById.length; i++) { | ||||
| 				const current = sortedById[i]; | ||||
| 				const previous = sortedById[i - 1]; | ||||
| 				assert.notEqual( | ||||
| 					current.id, | ||||
| 					previous.id, | ||||
| 					`[${assertionLabel}] item ${i} should have a different ID from item ${i - 1}`, | ||||
| 				); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		const checkNoteState = async () => { | ||||
| 			const notes = [...await this.listNotes()]; | ||||
| 			const expectedNotes = [...await this.tracker_.listNotes()]; | ||||
|  | ||||
| 			notes.sort(compare); | ||||
| 			expectedNotes.sort(compare); | ||||
|  | ||||
| 			assertNoAdjacentEqualIds(notes, 'notes'); | ||||
| 			assertNoAdjacentEqualIds(expectedNotes, 'expectedNotes'); | ||||
| 			assert.deepEqual(notes, expectedNotes, 'should have the same notes as the expected state'); | ||||
| 		}; | ||||
|  | ||||
| 		const checkFolderState = async () => { | ||||
| 			const folders = [...await this.listFolders()]; | ||||
| 			const expectedFolders = [...await this.tracker_.listFolders()]; | ||||
|  | ||||
| 			folders.sort(compare); | ||||
| 			expectedFolders.sort(compare); | ||||
|  | ||||
| 			assertNoAdjacentEqualIds(folders, 'folders'); | ||||
| 			assertNoAdjacentEqualIds(expectedFolders, 'expectedFolders'); | ||||
| 			assert.deepEqual(folders, expectedFolders, 'should have the same folders as the expected state'); | ||||
| 		}; | ||||
|  | ||||
| 		await checkNoteState(); | ||||
| 		await checkFolderState(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export default Client; | ||||
|  | ||||
							
								
								
									
										54
									
								
								packages/tools/fuzzer/ClientPool.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| import ActionTracker from './ActionTracker'; | ||||
| import Client from './Client'; | ||||
| import { CleanupTask, FuzzContext } from './types'; | ||||
|  | ||||
| type AddCleanupTask = (task: CleanupTask)=> void; | ||||
| type ClientFilter = (client: Client)=> boolean; | ||||
|  | ||||
| export default class ClientPool { | ||||
| 	public static async create( | ||||
| 		context: FuzzContext, | ||||
| 		clientCount: number, | ||||
| 		addCleanupTask: AddCleanupTask, | ||||
| 	) { | ||||
| 		if (clientCount <= 0) throw new Error('There must be at least 1 client'); | ||||
|  | ||||
| 		const actionTracker = new ActionTracker(context); | ||||
| 		const clientPool: Client[] = []; | ||||
| 		for (let i = 0; i < clientCount; i++) { | ||||
| 			const client = await Client.create(actionTracker, context); | ||||
| 			addCleanupTask(() => client.close()); | ||||
| 			clientPool.push(client); | ||||
| 		} | ||||
|  | ||||
| 		return new ClientPool(context, clientPool); | ||||
| 	} | ||||
| 	public constructor( | ||||
| 		private readonly context_: FuzzContext, | ||||
| 		public readonly clients: Client[], | ||||
| 	) { } | ||||
|  | ||||
| 	public randomClient(filter: ClientFilter = ()=>true) { | ||||
| 		const clients = this.clients.filter(filter); | ||||
| 		return clients[ | ||||
| 			this.context_.randInt(0, clients.length) | ||||
| 		]; | ||||
| 	} | ||||
|  | ||||
| 	public async checkState() { | ||||
| 		for (const client of this.clients) { | ||||
| 			await client.checkState(this.clients); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async syncAll() { | ||||
| 		for (const client of this.clients) { | ||||
| 			await client.sync(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public helpText() { | ||||
| 		return this.clients.map(client => client.getHelpText()).join('\n\n'); | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										79
									
								
								packages/tools/fuzzer/Server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,79 @@ | ||||
| import { join } from 'path'; | ||||
| import { HttpMethod, Json, UserData } from './types'; | ||||
| import { packagesDir } from './constants'; | ||||
| import JoplinServerApi from '@joplin/lib/JoplinServerApi'; | ||||
| import { Env } from '@joplin/lib/models/Setting'; | ||||
| import execa = require('execa'); | ||||
| import { msleep } from '@joplin/utils/time'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
|  | ||||
| const logger = Logger.create('Server'); | ||||
|  | ||||
| const createApi = async (serverUrl: string, adminAuth: UserData) => { | ||||
| 	const api = new JoplinServerApi({ | ||||
| 		baseUrl: () => serverUrl, | ||||
| 		userContentBaseUrl: () => serverUrl, | ||||
| 		password: () => adminAuth.password, | ||||
| 		username: () => adminAuth.email, | ||||
| 		session: ()=>null, | ||||
| 		env: Env.Dev, | ||||
| 	}); | ||||
| 	await api.loadSession(); | ||||
| 	return api; | ||||
| }; | ||||
|  | ||||
| export default class Server { | ||||
| 	private api_: JoplinServerApi|null = null; | ||||
| 	private server_: execa.ExecaChildProcess<string>; | ||||
|  | ||||
| 	public constructor( | ||||
| 		private readonly serverUrl_: string, | ||||
| 		private readonly adminAuth_: UserData, | ||||
| 	) { | ||||
| 		const serverDir = join(packagesDir, 'server'); | ||||
| 		const mainEntrypoint = join(serverDir, 'dist', 'app.js'); | ||||
| 		this.server_ = execa.node(mainEntrypoint, [ | ||||
| 			'--env', 'dev', | ||||
| 		], { | ||||
| 			env: { JOPLIN_IS_TESTING: '1' }, | ||||
| 			cwd: join(packagesDir, 'server'), | ||||
| 			stdin: 'ignore', // No stdin | ||||
| 			// For debugging: | ||||
| 			// stderr: process.stderr, | ||||
| 			// stdout: process.stdout, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public async checkConnection() { | ||||
| 		let lastError; | ||||
| 		for (let retry = 0; retry < 30; retry++) { | ||||
| 			try { | ||||
| 				const response = await fetch(`${this.serverUrl_}api/ping`); | ||||
| 				if (response.ok) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} catch (error) { | ||||
| 				lastError = error; | ||||
| 			} | ||||
| 			await msleep(500); | ||||
| 		} | ||||
| 		if (lastError) { | ||||
| 			throw lastError; | ||||
| 		} | ||||
| 		return false; | ||||
| 	} | ||||
|  | ||||
| 	public async execApi(method: HttpMethod, route: string, action: Json) { | ||||
| 		this.api_ ??= await createApi(this.serverUrl_, this.adminAuth_); | ||||
| 		logger.debug('API EXEC', method, route, action); | ||||
| 		const result = await this.api_.exec(method, route, {}, action); | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	public async close() { | ||||
| 		this.server_.cancel(); | ||||
| 		logger.info('Closed the server.'); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
							
								
								
									
										5
									
								
								packages/tools/fuzzer/constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| import { dirname, join } from 'path'; | ||||
|  | ||||
| export const packagesDir = dirname(dirname(__dirname)); | ||||
| export const cliDirectory = join(packagesDir, 'app-cli'); | ||||
|  | ||||
							
								
								
									
										362
									
								
								packages/tools/fuzzer/sync-fuzzer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,362 @@ | ||||
| import uuid from '@joplin/lib/uuid'; | ||||
| import { join } from 'path'; | ||||
| import { exists, mkdir, remove } from 'fs-extra'; | ||||
| import Setting, { Env } from '@joplin/lib/models/Setting'; | ||||
| import Logger, { TargetType } from '@joplin/utils/Logger'; | ||||
| import { waitForCliInput } from '@joplin/utils/cli'; | ||||
| import Server from './Server'; | ||||
| import { CleanupTask, FuzzContext } from './types'; | ||||
| import ClientPool from './ClientPool'; | ||||
| import retryWithCount from './utils/retryWithCount'; | ||||
| import Client from './Client'; | ||||
| import SeededRandom from './utils/SeededRandom'; | ||||
| import { env } from 'process'; | ||||
| import yargs = require('yargs'); | ||||
| import { strict as assert } from 'assert'; | ||||
| const { shimInit } = require('@joplin/lib/shim-init-node'); | ||||
|  | ||||
| const globalLogger = new Logger(); | ||||
| globalLogger.addTarget(TargetType.Console); | ||||
| Logger.initializeGlobalLogger(globalLogger); | ||||
| const logger = Logger.create('fuzzer'); | ||||
|  | ||||
| const createProfilesDirectory = async () => { | ||||
| 	const path = join(__dirname, 'profiles-tmp'); | ||||
| 	if (await exists(path)) { | ||||
| 		throw new Error([ | ||||
| 			'Another instance of the sync fuzzer may be running!', | ||||
| 			'The parent directory for test profiles already exists. An instance of the fuzzer is either already running or was closed before it could clean up.', | ||||
| 			`To ignore this issue, delete ${JSON.stringify(path)} and re-run the fuzzer.`, | ||||
| 		].join('\n')); | ||||
| 	} | ||||
|  | ||||
| 	await mkdir(path); | ||||
| 	return { | ||||
| 		path, | ||||
| 		remove: async () => { | ||||
| 			await remove(path); | ||||
| 		}, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| const doRandomAction = async (context: FuzzContext, client: Client, clientPool: ClientPool) => { | ||||
| 	const selectOrCreateParentFolder = async () => { | ||||
| 		let parentId = (await client.randomFolder({}))?.id; | ||||
|  | ||||
| 		// Create a toplevel folder to serve as this | ||||
| 		// folder's parent if none exist yet | ||||
| 		if (!parentId) { | ||||
| 			parentId = uuid.create(); | ||||
| 			await client.createFolder({ | ||||
| 				parentId: '', | ||||
| 				id: parentId, | ||||
| 				title: 'Parent folder', | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		return parentId; | ||||
| 	}; | ||||
|  | ||||
| 	const selectOrCreateNote = async () => { | ||||
| 		let note = await client.randomNote(); | ||||
|  | ||||
| 		if (!note) { | ||||
| 			await client.createNote({ | ||||
| 				parentId: await selectOrCreateParentFolder(), | ||||
| 				id: uuid.create(), | ||||
| 				title: 'Test note', | ||||
| 				body: 'Body', | ||||
| 			}); | ||||
|  | ||||
| 			note = await client.randomNote(); | ||||
| 			assert.ok(note, 'should have selected a random note'); | ||||
| 		} | ||||
|  | ||||
| 		return note; | ||||
| 	}; | ||||
|  | ||||
| 	const actions = { | ||||
| 		newSubfolder: async () => { | ||||
| 			const folderId = uuid.create(); | ||||
| 			const parentId = await selectOrCreateParentFolder(); | ||||
|  | ||||
| 			await client.createFolder({ | ||||
| 				parentId: parentId, | ||||
| 				id: folderId, | ||||
| 				title: 'Subfolder', | ||||
| 			}); | ||||
|  | ||||
| 			return true; | ||||
| 		}, | ||||
| 		newToplevelFolder: async () => { | ||||
| 			const folderId = uuid.create(); | ||||
| 			await client.createFolder({ | ||||
| 				parentId: null, | ||||
| 				id: folderId, | ||||
| 				title: `Folder ${context.randInt(0, 1000)}`, | ||||
| 			}); | ||||
|  | ||||
| 			return true; | ||||
| 		}, | ||||
| 		newNote: async () => { | ||||
| 			const parentId = await selectOrCreateParentFolder(); | ||||
| 			await client.createNote({ | ||||
| 				parentId: parentId, | ||||
| 				title: `Test (x${context.randInt(0, 1000)})`, | ||||
| 				body: 'Testing...', | ||||
| 				id: uuid.create(), | ||||
| 			}); | ||||
|  | ||||
| 			return true; | ||||
| 		}, | ||||
| 		renameNote: async () => { | ||||
| 			const note = await selectOrCreateNote(); | ||||
|  | ||||
| 			await client.updateNote({ | ||||
| 				...note, | ||||
| 				title: `Renamed (${context.randInt(0, 1000)})`, | ||||
| 			}); | ||||
|  | ||||
| 			return true; | ||||
| 		}, | ||||
| 		updateNoteBody: async () => { | ||||
| 			const note = await selectOrCreateNote(); | ||||
|  | ||||
| 			await client.updateNote({ | ||||
| 				...note, | ||||
| 				body: `${note.body}\n\nUpdated.\n`, | ||||
| 			}); | ||||
|  | ||||
| 			return true; | ||||
| 		}, | ||||
| 		moveNote: async () => { | ||||
| 			const note = await client.randomNote(); | ||||
| 			if (!note) return false; | ||||
| 			const targetParent = await client.randomFolder({ | ||||
| 				filter: folder => folder.id !== note.parentId, | ||||
| 			}); | ||||
| 			if (!targetParent) return false; | ||||
|  | ||||
| 			await client.moveItem(note.id, targetParent.id); | ||||
|  | ||||
| 			return true; | ||||
| 		}, | ||||
| 		shareFolder: async () => { | ||||
| 			const target = await client.randomFolder({ | ||||
| 				filter: candidate => ( | ||||
| 					!candidate.parentId && !candidate.isShareRoot | ||||
| 				), | ||||
| 			}); | ||||
| 			if (!target) return false; | ||||
|  | ||||
| 			const other = clientPool.randomClient(c => c !== client); | ||||
| 			await client.shareFolder(target.id, other); | ||||
| 			return true; | ||||
| 		}, | ||||
| 		deleteFolder: async () => { | ||||
| 			const target = await client.randomFolder({}); | ||||
| 			if (!target) return false; | ||||
|  | ||||
| 			await client.deleteFolder(target.id); | ||||
| 			return true; | ||||
| 		}, | ||||
| 		moveFolderToToplevel: async () => { | ||||
| 			const target = await client.randomFolder({ | ||||
| 				// Don't choose items that are already toplevel | ||||
| 				filter: item => !!item.parentId, | ||||
| 			}); | ||||
| 			if (!target) return false; | ||||
|  | ||||
| 			await client.moveItem(target.id, ''); | ||||
| 			return true; | ||||
| 		}, | ||||
| 		moveFolderTo: async () => { | ||||
| 			const target = await client.randomFolder({ | ||||
| 				// Don't move shared folders (should not be allowed by the GUI in the main apps). | ||||
| 				filter: item => !item.isShareRoot, | ||||
| 			}); | ||||
| 			if (!target) return false; | ||||
|  | ||||
| 			const targetDescendants = new Set(await client.allFolderDescendants(target.id)); | ||||
|  | ||||
| 			const newParent = await client.randomFolder({ | ||||
| 				filter: (item) => { | ||||
| 					// Avoid making the folder a child of itself | ||||
| 					return !targetDescendants.has(item.id); | ||||
| 				}, | ||||
| 			}); | ||||
| 			if (!newParent) return false; | ||||
|  | ||||
| 			await client.moveItem(target.id, newParent.id); | ||||
| 			return true; | ||||
| 		}, | ||||
| 	}; | ||||
|  | ||||
| 	const actionKeys = [...Object.keys(actions)] as (keyof typeof actions)[]; | ||||
|  | ||||
| 	let result = false; | ||||
| 	while (!result) { // Loop until an action was done | ||||
| 		const randomAction = actionKeys[context.randInt(0, actionKeys.length)]; | ||||
| 		logger.info(`Action: ${randomAction} in ${client.email}`); | ||||
| 		result = await actions[randomAction](); | ||||
| 		if (!result) { | ||||
| 			logger.info(`  ${randomAction} was skipped (preconditions not met).`); | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| interface Options { | ||||
| 	seed: number; | ||||
| 	maximumSteps: number; | ||||
| 	maximumStepsBetweenSyncs: number; | ||||
| 	clientCount: number; | ||||
| } | ||||
|  | ||||
| const main = async (options: Options) => { | ||||
| 	shimInit(); | ||||
| 	Setting.setConstant('env', Env.Dev); | ||||
|  | ||||
| 	const cleanupTasks: CleanupTask[] = []; | ||||
|  | ||||
| 	const cleanUp = async () => { | ||||
| 		logger.info('Cleaning up....'); | ||||
| 		while (cleanupTasks.length) { | ||||
| 			const task = cleanupTasks.pop(); | ||||
| 			try { | ||||
| 				await task(); | ||||
| 			} catch (error) { | ||||
| 				logger.warn('Clean up task failed:', error); | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	// Run cleanup on Ctrl-C | ||||
| 	process.on('SIGINT', async () => { | ||||
| 		logger.info('Intercepted ctrl-c. Cleaning up...'); | ||||
| 		await cleanUp(); | ||||
| 		process.exit(1); | ||||
| 	}); | ||||
|  | ||||
| 	let clientHelpText; | ||||
|  | ||||
| 	try { | ||||
| 		const joplinServerUrl = 'http://localhost:22300/'; | ||||
| 		const server = new Server(joplinServerUrl, { | ||||
| 			email: 'admin@localhost', | ||||
| 			password: env['FUZZER_SERVER_ADMIN_PASSWORD'] ?? 'admin', | ||||
| 		}); | ||||
| 		cleanupTasks.push(() => server.close()); | ||||
|  | ||||
| 		if (!await server.checkConnection()) { | ||||
| 			throw new Error('Could not connect to the server.'); | ||||
| 		} | ||||
|  | ||||
| 		const profilesDirectory = await createProfilesDirectory(); | ||||
| 		cleanupTasks.push(profilesDirectory.remove); | ||||
|  | ||||
| 		logger.info('Starting with seed', options.seed); | ||||
| 		const random = new SeededRandom(options.seed); | ||||
|  | ||||
| 		const fuzzContext: FuzzContext = { | ||||
| 			serverUrl: joplinServerUrl, | ||||
| 			baseDir: profilesDirectory.path, | ||||
| 			execApi: server.execApi.bind(server), | ||||
| 			randInt: (a, b) => random.nextInRange(a, b), | ||||
| 		}; | ||||
| 		const clientPool = await ClientPool.create( | ||||
| 			fuzzContext, | ||||
| 			options.clientCount, | ||||
| 			task => { cleanupTasks.push(task); }, | ||||
| 		); | ||||
| 		clientHelpText = clientPool.helpText(); | ||||
|  | ||||
| 		const maxSteps = options.maximumSteps; | ||||
| 		for (let stepIndex = 1; maxSteps <= 0 || stepIndex <= maxSteps; stepIndex++) { | ||||
| 			const client = clientPool.randomClient(); | ||||
|  | ||||
| 			// Ensure that the client starts up-to-date with the other synced clients. | ||||
| 			await client.sync(); | ||||
|  | ||||
| 			logger.info('Step', stepIndex, '/', maxSteps > 0 ? maxSteps : 'Infinity'); | ||||
| 			const actionsBeforeFullSync = fuzzContext.randInt(1, options.maximumStepsBetweenSyncs + 1); | ||||
| 			for (let subStepIndex = 1; subStepIndex <= actionsBeforeFullSync; subStepIndex++) { | ||||
| 				if (actionsBeforeFullSync > 1) { | ||||
| 					logger.info('Sub-step', subStepIndex, '/', actionsBeforeFullSync, '(in step', stepIndex, ')'); | ||||
| 				} | ||||
| 				await doRandomAction(fuzzContext, client, clientPool); | ||||
| 			} | ||||
| 			await client.sync(); | ||||
|  | ||||
| 			// .checkState can fail occasionally due to incomplete | ||||
| 			// syncs (perhaps because the server is still processing | ||||
| 			// share-related changes?). Allow this to be retried: | ||||
| 			await retryWithCount(async () => { | ||||
| 				await clientPool.checkState(); | ||||
| 			}, { | ||||
| 				count: 3, | ||||
| 				onFail: async () => { | ||||
| 					logger.info('.checkState failed. Syncing all clients...'); | ||||
| 					await clientPool.syncAll(); | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
| 	} catch (error) { | ||||
| 		logger.error('ERROR', error); | ||||
| 		if (clientHelpText) { | ||||
| 			logger.info('Client information:\n', clientHelpText); | ||||
| 		} | ||||
| 		logger.info('An error occurred. Pausing before continuing cleanup.'); | ||||
| 		await waitForCliInput(); | ||||
| 		process.exitCode = 1; | ||||
| 	} finally { | ||||
| 		await cleanUp(); | ||||
|  | ||||
| 		logger.info('Cleanup complete'); | ||||
| 		process.exit(); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
|  | ||||
| void yargs | ||||
| 	.usage('$0 <cmd>') | ||||
| 	.command( | ||||
| 		'start', | ||||
| 		[ | ||||
| 			'Starts the synchronization fuzzer. The fuzzer starts Joplin Server, creates multiple CLI clients, and attempts to find sync bugs.\n\n', | ||||
| 			'The fuzzer starts Joplin Server in development mode, using the existing development mode database and uses the admin@localhost user to', | ||||
| 			'create and set up user accounts.\n', | ||||
| 			'Use the FUZZER_SERVER_ADMIN_PASSWORD environment variable to specify the admin@localhost password for this dev version of Joplin Server.\n\n', | ||||
| 			'If the fuzzer detects incorrect/unexpected client state, it pauses, allowing the profile directories and databases', | ||||
| 			'of the clients to be inspected.', | ||||
| 		].join(' '), | ||||
| 		(yargs) => { | ||||
| 			return yargs.options({ | ||||
| 				'seed': { type: 'number', default: 12345 }, | ||||
| 				'steps': { | ||||
| 					type: 'number', | ||||
| 					default: 0, | ||||
| 					defaultDescription: 'The maximum number of steps to take before stopping the fuzzer. Set to zero for an unlimited number of steps.', | ||||
| 				}, | ||||
| 				'steps-between-syncs': { | ||||
| 					type: 'number', | ||||
| 					default: 3, | ||||
| 					defaultDescription: 'The maximum number of sub-steps taken before all clients are synchronised.', | ||||
| 				}, | ||||
| 				'clients': { | ||||
| 					type: 'number', | ||||
| 					default: 3, | ||||
| 					defaultDescription: 'Number of client apps to create.', | ||||
| 				}, | ||||
| 			}); | ||||
| 		}, | ||||
| 		async (argv) => { | ||||
| 			await main({ | ||||
| 				seed: argv.seed, | ||||
| 				maximumSteps: argv.steps, | ||||
| 				clientCount: argv.clients, | ||||
| 				maximumStepsBetweenSyncs: argv['steps-between-syncs'], | ||||
| 			}); | ||||
| 		}, | ||||
| 	) | ||||
| 	.help() | ||||
| 	.argv; | ||||
							
								
								
									
										62
									
								
								packages/tools/fuzzer/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| import type Client from './Client'; | ||||
|  | ||||
| export type Json = string|number|Json[]|{ [key: string]: Json }; | ||||
|  | ||||
| export type HttpMethod = 'GET'|'POST'|'DELETE'|'PUT'|'PATCH'; | ||||
|  | ||||
| export type ItemId = string; | ||||
| export type NoteData = { | ||||
| 	parentId: ItemId; | ||||
| 	id: ItemId; | ||||
| 	title: string; | ||||
| 	body: string; | ||||
| }; | ||||
| export type FolderMetadata = { | ||||
| 	parentId: ItemId; | ||||
| 	id: ItemId; | ||||
| 	title: string; | ||||
| }; | ||||
| export type FolderData = FolderMetadata & { | ||||
| 	childIds: ItemId[]; | ||||
| 	isShareRoot: boolean; | ||||
| }; | ||||
| export type TreeItem = NoteData|FolderData; | ||||
|  | ||||
| export const isFolder = (item: TreeItem): item is FolderData => { | ||||
| 	return 'childIds' in item; | ||||
| }; | ||||
|  | ||||
| export interface FuzzContext { | ||||
| 	serverUrl: string; | ||||
| 	baseDir: string; | ||||
| 	execApi: (method: HttpMethod, route: string, debugAction: Json)=> Promise<Json>; | ||||
| 	randInt: (low: number, high: number)=> number; | ||||
| } | ||||
|  | ||||
| export interface RandomFolderOptions { | ||||
| 	filter?: (folder: FolderData)=> boolean; | ||||
| } | ||||
|  | ||||
| export interface ActionableClient { | ||||
| 	createFolder(data: FolderMetadata): Promise<void>; | ||||
| 	shareFolder(id: ItemId, shareWith: Client): Promise<void>; | ||||
| 	deleteFolder(id: ItemId): Promise<void>; | ||||
| 	createNote(data: NoteData): Promise<void>; | ||||
| 	updateNote(data: NoteData): Promise<void>; | ||||
| 	moveItem(itemId: ItemId, newParentId: ItemId): Promise<void>; | ||||
| 	sync(): Promise<void>; | ||||
|  | ||||
| 	listNotes(): Promise<NoteData[]>; | ||||
| 	listFolders(): Promise<FolderMetadata[]>; | ||||
| 	allFolderDescendants(parentId: ItemId): Promise<ItemId[]>; | ||||
| 	randomFolder(options: RandomFolderOptions): Promise<FolderMetadata>; | ||||
| 	randomNote(): Promise<NoteData>; | ||||
| } | ||||
|  | ||||
| export interface UserData { | ||||
| 	email: string; | ||||
| 	password: string; | ||||
| } | ||||
|  | ||||
| export type CleanupTask = ()=> Promise<void>; | ||||
|  | ||||
							
								
								
									
										52
									
								
								packages/tools/fuzzer/utils/SeededRandom.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | ||||
|  | ||||
| // SeededRandom provides a very simple random number generator | ||||
| // that can be seeded (since NodeJS built-ins can't). | ||||
| // | ||||
| // See also: | ||||
| // - https://arxiv.org/pdf/1704.00358 | ||||
| // - https://en.wikipedia.org/wiki/Middle-square_method | ||||
|  | ||||
| // Some large odd number, see https://en.wikipedia.org/wiki/Weyl_sequence | ||||
| const step = BigInt('0x12345678ABCDE123'); // uint64 | ||||
| const maxSize = BigInt(1) << BigInt(64); | ||||
|  | ||||
| const extractMiddle = (value: bigint, halfSize: bigint) => { | ||||
| 	// Remove the lower quarter | ||||
| 	const quarterSize = halfSize / BigInt(2); | ||||
| 	value >>= quarterSize; | ||||
|  | ||||
| 	// Remove the upper quarter | ||||
| 	const halfMaximumValue = BigInt(1) << halfSize; | ||||
| 	value %= halfMaximumValue; | ||||
|  | ||||
| 	return value; | ||||
| }; | ||||
|  | ||||
| export default class SeededRandom { | ||||
| 	private value_: bigint; | ||||
| 	private nextStep_: bigint = step; | ||||
| 	private halfSize_ = BigInt(32); | ||||
|  | ||||
| 	public constructor(seed: number) { | ||||
| 		this.value_ = BigInt(seed); | ||||
| 	} | ||||
|  | ||||
| 	public next() { | ||||
| 		this.value_ = this.value_ * this.value_ + this.nextStep_; | ||||
|  | ||||
| 		// Move to the next item in the sequence. Mod to prevent from getting | ||||
| 		// too large. See https://en.wikipedia.org/wiki/Weyl_sequence. | ||||
| 		this.nextStep_ = (step + this.nextStep_) % maxSize; | ||||
|  | ||||
| 		this.value_ = extractMiddle(this.value_, this.halfSize_); | ||||
| 		return this.value_; | ||||
| 	} | ||||
|  | ||||
| 	// The resultant range includes `a` but excludes `b`. | ||||
| 	public nextInRange(a: number, b: number) { | ||||
| 		if (b <= a + 1) return a; | ||||
|  | ||||
| 		const range = b - a; | ||||
| 		return Number(this.next() % BigInt(range)) + a; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										11
									
								
								packages/tools/fuzzer/utils/getNumberProperty.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| import getProperty from './getProperty'; | ||||
|  | ||||
| const getNumberProperty = (object: unknown, propertyName: string) => { | ||||
| 	const value = getProperty(object, propertyName); | ||||
| 	if (typeof value !== 'number') { | ||||
| 		throw new Error(`Property value is not a string (is ${typeof value})`); | ||||
| 	} | ||||
| 	return value; | ||||
| }; | ||||
|  | ||||
| export default getNumberProperty; | ||||
							
								
								
									
										15
									
								
								packages/tools/fuzzer/utils/getProperty.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
|  | ||||
| const getProperty = (object: unknown, propertyName: string) => { | ||||
| 	if (typeof object !== 'object' || object === null) { | ||||
| 		throw new Error(`Cannot access property ${JSON.stringify(propertyName)} on non-object`); | ||||
| 	} | ||||
|  | ||||
| 	if (!(propertyName in object)) { | ||||
| 		throw new Error(`No such property ${JSON.stringify(propertyName)} in object`); | ||||
| 	} | ||||
|  | ||||
| 	return object[propertyName as keyof object]; | ||||
| }; | ||||
|  | ||||
| export default getProperty; | ||||
|  | ||||
							
								
								
									
										11
									
								
								packages/tools/fuzzer/utils/getStringProperty.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| import getProperty from './getProperty'; | ||||
|  | ||||
| const getStringProperty = (object: unknown, propertyName: string) => { | ||||
| 	const value = getProperty(object, propertyName); | ||||
| 	if (typeof value !== 'string') { | ||||
| 		throw new Error(`Property value is not a string (is ${typeof value})`); | ||||
| 	} | ||||
| 	return value; | ||||
| }; | ||||
|  | ||||
| export default getStringProperty; | ||||
							
								
								
									
										21
									
								
								packages/tools/fuzzer/utils/retryWithCount.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| interface Options { | ||||
| 	count: number; | ||||
| 	onFail: (error: Error)=> Promise<void>; | ||||
| } | ||||
|  | ||||
| const retryWithCount = async (task: ()=> Promise<void>, { count, onFail }: Options) => { | ||||
| 	let lastError: Error|null = null; | ||||
| 	for (let retry = 0; retry < count; retry ++) { | ||||
| 		try { | ||||
| 			return await task(); | ||||
| 		} catch (error) { | ||||
| 			await onFail(error); | ||||
| 			lastError = error; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if (lastError) throw lastError; | ||||
| }; | ||||
|  | ||||
| export default retryWithCount; | ||||
|  | ||||
| @@ -159,6 +159,8 @@ | ||||
| 		"v3.3.13": true, | ||||
| 		"android-v3.3.9": true, | ||||
| 		"android-v3.3.10": true, | ||||
| 		"ios-v13.3.8": true | ||||
| 		"ios-v13.3.8": true, | ||||
| 		"android-v3.3.11": true, | ||||
| 		"ios-v13.3.9": true | ||||
| 	} | ||||
| } | ||||
| @@ -95,11 +95,6 @@ | ||||
| 			"imageName": "Route4Me.png", | ||||
| 			"githubUser": "route4me" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://casinoreviews.net", | ||||
| 			"title": "Casino Reviews", | ||||
| 			"imageName": "CasinoReviews.png" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://topagency.webflow.io", | ||||
| 			"title": "WebDesignAgency", | ||||
| @@ -124,13 +119,6 @@ | ||||
| 			"imageName": "Slotozilla.png", | ||||
| 			"alt": "casino without making any upfront cost" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://www.reddit.com/r/tiktokRise/", | ||||
| 			"title": "Tiktok Rise", | ||||
| 			"imageName": "TiktokRise.jpg", | ||||
| 			"alt": "Tiktok Rise", | ||||
| 			"githubUser": "knickman" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://essaywriter.pro", | ||||
| 			"title": "write my essay services by EssayWriter", | ||||
| @@ -154,6 +142,26 @@ | ||||
| 			"title": "high-quality paper writing service PaperWriter", | ||||
| 			"imageName": "PaperWriter.png", | ||||
| 			"alt": "high-quality paper writing service PaperWriter" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://homeworkguy.org/someone-to-take-my-online-class", | ||||
| 			"title": "someone to take my online class", | ||||
| 			"imageName": "HomeworkGuy.png", | ||||
| 			"alt": "someone to take my online class", | ||||
| 			"githubUser": "Nftsworld007" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://www.bestetf.net/", | ||||
| 			"title": "BestETF", | ||||
| 			"imageName": "BestEtf.png", | ||||
| 			"alt": "BestETF", | ||||
| 			"githubUser": "traspire" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://freespinny.io/free-spins-no-deposit/", | ||||
| 			"title": "Freespinny.io Free Spins Bonus site", | ||||
| 			"imageName": "Freespinny.png", | ||||
| 			"alt": "Freespinny.io Free Spins Bonus site" | ||||
| 		} | ||||
| 	], | ||||
| 	"orgsOld": [ | ||||
| @@ -195,6 +203,13 @@ | ||||
| 			"title": "Achieve academic success with Edubirdie — your trusted partner for expert writing assistance and resources!", | ||||
| 			"imageName": "Edubirdie.png", | ||||
| 			"alt": "EduBirdie" | ||||
| 		}, | ||||
| 		{ | ||||
| 			"url": "https://www.reddit.com/r/tiktokRise/", | ||||
| 			"title": "Tiktok Rise", | ||||
| 			"imageName": "TiktokRise.jpg", | ||||
| 			"alt": "Tiktok Rise", | ||||
| 			"githubUser": "knickman" | ||||
| 		} | ||||
| 	] | ||||
| } | ||||
|   | ||||
| @@ -19,7 +19,7 @@ | ||||
|     "file-type": "16.5.4", | ||||
|     "fs-extra": "11.2.0", | ||||
|     "knex": "3.1.0", | ||||
|     "koa": "2.16.0", | ||||
|     "koa": "2.16.1", | ||||
|     "koa-body": "6.0.1", | ||||
|     "pg-boss": "10.1.6", | ||||
|     "sqlite3": "5.1.6" | ||||
|   | ||||
| @@ -15,7 +15,7 @@ | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@rollup/plugin-commonjs": "28.0.3", | ||||
|     "@rollup/plugin-node-resolve": "15.3.1", | ||||
|     "@rollup/plugin-node-resolve": "16.0.1", | ||||
|     "@rollup/plugin-replace": "6.0.2", | ||||
|     "browserify": "14.5.0", | ||||
|     "rollup": "4.2.0", | ||||
|   | ||||
| @@ -124,6 +124,8 @@ export function getStyleProp(node, name) { | ||||
|  | ||||
|   const o = css.parse('div {' + style + '}'); | ||||
|   if (!o.stylesheet.rules.length) return null; | ||||
|   const prop = o.stylesheet.rules[0].declarations.find(d => d.property.toLowerCase() === name); | ||||
|   const prop = o.stylesheet.rules[0].declarations.find(d => { | ||||
|     return d.type === 'declaration' && d.property.toLowerCase() === name; | ||||
|   }); | ||||
|   return prop ? prop.value : null; | ||||
| } | ||||
|   | ||||
| @@ -1,16 +1,17 @@ | ||||
| 
 | ||||
| const readline = require('readline/promises'); | ||||
| 
 | ||||
| /* eslint-disable no-console */ | ||||
| 
 | ||||
| export const isTTY = () => process.stdin.isTTY; | ||||
| 
 | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
 | ||||
| let readlineInterface: any = null; | ||||
| const waitForCliInput = async () => { | ||||
| export const waitForCliInput = async () => { | ||||
| 	readlineInterface ??= readline.createInterface({ | ||||
| 		input: process.stdin, | ||||
| 		output: process.stdout, | ||||
| 	}); | ||||
| 	if (process.stdin.isTTY) { | ||||
| 	if (isTTY()) { | ||||
| 		const green = '\x1b[92m'; | ||||
| 		const reset = '\x1b[0m'; | ||||
| 		await readlineInterface.question(`${green}[Press enter to continue]${reset}`); | ||||
| @@ -21,4 +22,3 @@ const waitForCliInput = async () => { | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default waitForCliInput; | ||||
| @@ -18,7 +18,8 @@ | ||||
|     "./types": "./dist/types.js", | ||||
|     "./url": "./dist/url.js", | ||||
|     "./ipc": "./dist/ipc.js", | ||||
|     "./path": "./dist/path.js" | ||||
|     "./path": "./dist/path.js", | ||||
|     "./cli": "./dist/cli.js" | ||||
|   }, | ||||
|   "publishConfig": { | ||||
|     "access": "public" | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| # Joplin Android Changelog | ||||
|  | ||||
| ## [android-v3.3.11](https://github.com/laurent22/joplin/releases/tag/android-v3.3.11) (Pre-release) - 2025-07-09T22:51:55Z | ||||
|  | ||||
| - Fixed: Biometrics: Fix notebook list can still be accessed when the app is locked (#12691 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
|  | ||||
| ## [android-v3.3.10](https://github.com/laurent22/joplin/releases/tag/android-v3.3.10) (Pre-release) - 2025-06-10T08:07:25Z | ||||
|  | ||||
| - New: Add additional checks when updating sidebar state (#12428 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
|   | ||||
| @@ -1,5 +1,10 @@ | ||||
| # Joplin iOS Changelog | ||||
|  | ||||
| ## [ios-v13.3.9](https://github.com/laurent22/joplin/releases/tag/ios-v13.3.9) - 2025-07-09T23:17:23Z | ||||
|  | ||||
| - New: Add additional checks when updating sidebar state (#12428 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
| - Fixed: Biometrics: Fix notebook list can still be accessed when the app is locked (#12691 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
|  | ||||
| ## [ios-v13.3.8](https://github.com/laurent22/joplin/releases/tag/ios-v13.3.8) - 2025-06-09T17:15:06Z | ||||
|  | ||||
| - Fixed: Fix error shown the first time a user attempts to record (#12328) (#12314 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator)) | ||||
|   | ||||
| @@ -1,236 +1,237 @@ | ||||
| --- | ||||
| updated: 2025-06-01T02:15:29Z | ||||
| updated: 2025-07-06T16:57:49Z | ||||
| --- | ||||
|  | ||||
| # Joplin statistics | ||||
|  | ||||
| | Name  | Value | | ||||
| | ----- | ----- | | ||||
| | Total Windows downloads | 6,559,219 | | ||||
| | Total macOs downloads | 1,988,312 | | ||||
| | Total Linux downloads | 1,516,553 | | ||||
| | Total Windows downloads | 6,736,767 | | ||||
| | Total macOs downloads | 2,006,140 | | ||||
| | Total Linux downloads | 1,545,047 | | ||||
| | Windows % | 65%   | | ||||
| | macOS % | 20%   | | ||||
| | macOS % | 19%   | | ||||
| | Linux % | 15%   | | ||||
|  | ||||
| (p) Indicates pre-releases | ||||
|  | ||||
| | Version | Date  | Windows | macOS | Linux | Total | | ||||
| | ----- | ----- | ----- | ----- | ----- | ----- | | ||||
| | [v3.4.1](https://github.com/laurent22/joplin/releases/tag/v3.4.1) (p) | 2025-05-20T09:59:39Z | 1,523 | 325   | 391   | 2,239 | | ||||
| | [v3.3.12](https://github.com/laurent22/joplin/releases/tag/v3.3.12) | 2025-05-04T18:12:23Z | 130,348 | 18,775 | 21,928 | 171,051 | | ||||
| | [v3.3.10](https://github.com/laurent22/joplin/releases/tag/v3.3.10) | 2025-05-02T19:46:15Z | 23,052 | 5,064 | 1,794 | 29,910 | | ||||
| | [v3.3.9](https://github.com/laurent22/joplin/releases/tag/v3.3.9) | 2025-05-01T21:02:12Z | 21,670 | 6,070 | 1,173 | 28,913 | | ||||
| | [v3.3.7](https://github.com/laurent22/joplin/releases/tag/v3.3.7) (p) | 2025-04-29T13:47:19Z | 716   | 0     | 160   | 876   | | ||||
| | [v3.3.6](https://github.com/laurent22/joplin/releases/tag/v3.3.6) (p) | 2025-04-24T12:27:20Z | 1,017 | 262   | 240   | 1,519 | | ||||
| | [v3.3.5](https://github.com/laurent22/joplin/releases/tag/v3.3.5) (p) | 2025-04-17T13:40:31Z | 1,349 | 281   | 297   | 1,927 | | ||||
| | [v3.3.4](https://github.com/laurent22/joplin/releases/tag/v3.3.4) (p) | 2025-04-07T20:23:35Z | 1,591 | 385   | 363   | 2,339 | | ||||
| | [v3.3.3](https://github.com/laurent22/joplin/releases/tag/v3.3.3) (p) | 2025-03-16T11:52:33Z | 2,665 | 797   | 930   | 4,392 | | ||||
| | [v3.2.13](https://github.com/laurent22/joplin/releases/tag/v3.2.13) | 2025-02-28T14:38:21Z | 220,987 | 32,528 | 44,285 | 297,800 | | ||||
| | [v3.3.2](https://github.com/laurent22/joplin/releases/tag/v3.3.2) (p) | 2025-02-19T17:34:26Z | 2,332 | 558   | 639   | 3,529 | | ||||
| | [v3.3.1](https://github.com/laurent22/joplin/releases/tag/v3.3.1) (p) | 2025-02-16T17:06:26Z | 828   | 166   | 162   | 1,156 | | ||||
| | [v3.2.12](https://github.com/laurent22/joplin/releases/tag/v3.2.12) | 2025-01-23T23:52:04Z | 151,832 | 25,189 | 27,900 | 204,921 | | ||||
| | [v3.2.11](https://github.com/laurent22/joplin/releases/tag/v3.2.11) | 2025-01-13T17:48:21Z | 65,814 | 14,811 | 6,855 | 87,480 | | ||||
| | [v3.2.10](https://github.com/laurent22/joplin/releases/tag/v3.2.10) (p) | 2025-01-10T10:17:28Z | 1,279 | 161   | 184   | 1,624 | | ||||
| | [v3.2.9](https://github.com/laurent22/joplin/releases/tag/v3.2.9) (p) | 2025-01-09T22:58:42Z | 354   | 84    | 50    | 488   | | ||||
| | [v3.2.7](https://github.com/laurent22/joplin/releases/tag/v3.2.7) (p) | 2025-01-06T16:35:41Z | 862   | 150   | 809   | 1,821 | | ||||
| | [v3.2.6](https://github.com/laurent22/joplin/releases/tag/v3.2.6) (p) | 2024-12-23T21:54:40Z | 1,723 | 329   | 474   | 2,526 | | ||||
| | [v3.2.5](https://github.com/laurent22/joplin/releases/tag/v3.2.5) (p) | 2024-12-18T10:41:13Z | 986   | 193   | 217   | 1,396 | | ||||
| | [v3.2.4](https://github.com/laurent22/joplin/releases/tag/v3.2.4) (p) | 2024-12-12T17:59:52Z | 1,002 | 146   | 232   | 1,380 | | ||||
| | [v3.2.3](https://github.com/laurent22/joplin/releases/tag/v3.2.3) (p) | 2024-11-18T00:09:05Z | 2,688 | 548   | 883   | 4,119 | | ||||
| | [v3.2.1](https://github.com/laurent22/joplin/releases/tag/v3.2.1) (p) | 2024-11-10T16:16:27Z | 1,232 | 227   | 345   | 1,804 | | ||||
| | [v3.1.24](https://github.com/laurent22/joplin/releases/tag/v3.1.24) | 2024-11-09T15:08:29Z | 208,026 | 33,468 | 43,792 | 285,286 | | ||||
| | [v3.1.23](https://github.com/laurent22/joplin/releases/tag/v3.1.23) | 2024-11-07T10:56:45Z | 27,269 | 6,728 | 1,520 | 35,517 | | ||||
| | [v3.1.22](https://github.com/laurent22/joplin/releases/tag/v3.1.22) | 2024-11-05T08:59:32Z | 29,049 | 8,695 | 1,223 | 38,967 | | ||||
| | [v3.1.20](https://github.com/laurent22/joplin/releases/tag/v3.1.20) | 2024-10-22T12:21:32Z | 94,807 | 19,206 | 13,917 | 127,930 | | ||||
| | [v3.1.18](https://github.com/laurent22/joplin/releases/tag/v3.1.18) (p) | 2024-10-11T23:27:10Z | 1,504 | 279   | 587   | 2,370 | | ||||
| | [v3.1.17](https://github.com/laurent22/joplin/releases/tag/v3.1.17) (p) | 2024-09-26T11:57:54Z | 1,698 | 348   | 531   | 2,577 | | ||||
| | [v3.1.15](https://github.com/laurent22/joplin/releases/tag/v3.1.15) (p) | 2024-09-17T09:15:10Z | 1,177 | 223   | 492   | 1,892 | | ||||
| | [v3.1.8](https://github.com/laurent22/joplin/releases/tag/v3.1.8) (p) | 2024-09-08T20:32:44Z | 1,217 | 252   | 332   | 1,801 | | ||||
| | [v3.1.6](https://github.com/laurent22/joplin/releases/tag/v3.1.6) (p) | 2024-09-02T13:19:40Z | 960   | 226   | 425   | 1,611 | | ||||
| | [v3.1.4](https://github.com/laurent22/joplin/releases/tag/v3.1.4) (p) | 2024-08-27T17:46:38Z | 941   | 174   | 247   | 1,362 | | ||||
| | [v3.0.15](https://github.com/laurent22/joplin/releases/tag/v3.0.15) | 2024-08-21T09:19:58Z | 203,212 | 37,802 | 45,129 | 286,143 | | ||||
| | [v3.1.3](https://github.com/laurent22/joplin/releases/tag/v3.1.3) (p) | 2024-08-17T13:08:21Z | 1,226 | 274   | 483   | 1,983 | | ||||
| | [v3.1.2](https://github.com/laurent22/joplin/releases/tag/v3.1.2) (p) | 2024-08-16T09:00:59Z | 436   | 101   | 82    | 619   | | ||||
| | [v3.1.1](https://github.com/laurent22/joplin/releases/tag/v3.1.1) (p) | 2024-08-10T11:36:02Z | 1,078 | 201   | 260   | 1,539 | | ||||
| | [v2.14.23](https://github.com/laurent22/joplin/releases/tag/v2.14.23) | 2024-08-07T11:15:25Z | 10,759 | 2,636 | 619   | 14,014 | | ||||
| | [v3.0.14](https://github.com/laurent22/joplin/releases/tag/v3.0.14) | 2024-07-28T13:55:50Z | 89,307 | 18,570 | 18,816 | 126,693 | | ||||
| | [v3.0.12](https://github.com/laurent22/joplin/releases/tag/v3.0.12) | 2024-07-02T17:11:14Z | 44,191 | 12,830 | 7,334 | 64,355 | | ||||
| | [v3.0.11](https://github.com/laurent22/joplin/releases/tag/v3.0.11) (p) | 2024-06-29T10:20:02Z | 839   | 162   | 264   | 1,265 | | ||||
| | [v3.0.10](https://github.com/laurent22/joplin/releases/tag/v3.0.10) (p) | 2024-06-19T15:24:07Z | 1,616 | 289   | 559   | 2,464 | | ||||
| | [v3.0.9](https://github.com/laurent22/joplin/releases/tag/v3.0.9) (p) | 2024-06-12T19:07:50Z | 1,264 | 258   | 377   | 1,899 | | ||||
| | [v3.0.8](https://github.com/laurent22/joplin/releases/tag/v3.0.8) (p) | 2024-05-22T14:20:45Z | 2,658 | 0     | 911   | 3,569 | | ||||
| | [v2.14.22](https://github.com/laurent22/joplin/releases/tag/v2.14.22) | 2024-05-22T19:19:02Z | 142,918 | 30,755 | 25,463 | 199,136 | | ||||
| | [v3.0.6](https://github.com/laurent22/joplin/releases/tag/v3.0.6) (p) | 2024-04-27T13:16:04Z | 3,003 | 687   | 860   | 4,550 | | ||||
| | [v3.0.3](https://github.com/laurent22/joplin/releases/tag/v3.0.3) (p) | 2024-04-18T15:41:38Z | 1,480 | 314   | 331   | 2,125 | | ||||
| | [v3.0.2](https://github.com/laurent22/joplin/releases/tag/v3.0.2) (p) | 2024-03-21T18:18:49Z | 2,864 | 718   | 1,098 | 4,680 | | ||||
| | [v2.14.20](https://github.com/laurent22/joplin/releases/tag/v2.14.20) | 2024-03-18T17:05:17Z | 192,850 | 39,529 | 38,229 | 270,608 | | ||||
| | [v2.14.19](https://github.com/laurent22/joplin/releases/tag/v2.14.19) | 2024-03-08T10:45:16Z | 64,882 | 18,469 | 8,550 | 91,901 | | ||||
| | [v2.14.17](https://github.com/laurent22/joplin/releases/tag/v2.14.17) | 2024-03-01T18:10:26Z | 63,543 | 18,636 | 7,617 | 89,796 | | ||||
| | [v2.14.16](https://github.com/laurent22/joplin/releases/tag/v2.14.16) (p) | 2024-02-22T22:49:10Z | 1,347 | 283   | 377   | 2,007 | | ||||
| | [v2.14.15](https://github.com/laurent22/joplin/releases/tag/v2.14.15) (p) | 2024-02-19T11:24:57Z | 851   | 179   | 189   | 1,219 | | ||||
| | [v2.14.14](https://github.com/laurent22/joplin/releases/tag/v2.14.14) (p) | 2024-02-10T16:03:08Z | 1,270 | 242   | 378   | 1,890 | | ||||
| | [v2.14.13](https://github.com/laurent22/joplin/releases/tag/v2.14.13) (p) | 2024-02-09T16:31:54Z | 409   | 80    | 79    | 568   | | ||||
| | [v2.14.12](https://github.com/laurent22/joplin/releases/tag/v2.14.12) (p) | 2024-02-03T12:11:47Z | 978   | 217   | 244   | 1,439 | | ||||
| | [v2.14.11](https://github.com/laurent22/joplin/releases/tag/v2.14.11) (p) | 2024-01-26T11:53:05Z | 1,264 | 258   | 459   | 1,981 | | ||||
| | [v2.14.10](https://github.com/laurent22/joplin/releases/tag/v2.14.10) (p) | 2024-01-18T22:45:04Z | 2,085 | 267   | 370   | 2,722 | | ||||
| | [v2.13.15](https://github.com/laurent22/joplin/releases/tag/v2.13.15) | 2024-01-15T13:01:19Z | 149,838 | 34,150 | 29,260 | 213,248 | | ||||
| | [v2.13.14](https://github.com/laurent22/joplin/releases/tag/v2.13.14) | 2024-01-13T19:11:04Z | 19,929 | 7,826 | 2,361 | 30,116 | | ||||
| | [v2.14.9](https://github.com/laurent22/joplin/releases/tag/v2.14.9) (p) | 2024-01-11T22:17:59Z | 1,055 | 0     | 241   | 1,296 | | ||||
| | [v2.14.8](https://github.com/laurent22/joplin/releases/tag/v2.14.8) (p) | 2024-01-09T22:57:07Z | 753   | 235   | 173   | 1,161 | | ||||
| | [v2.14.7](https://github.com/laurent22/joplin/releases/tag/v2.14.7) (p) | 2024-01-08T11:51:49Z | 621   | 127   | 173   | 921   | | ||||
| | [v2.14.6](https://github.com/laurent22/joplin/releases/tag/v2.14.6) (p) | 2024-01-06T16:38:32Z | 727   | 149   | 149   | 1,025 | | ||||
| | [v2.13.13](https://github.com/laurent22/joplin/releases/tag/v2.13.13) | 2024-01-06T13:33:11Z | 53,990 | 15,933 | 6,345 | 76,268 | | ||||
| | [v2.13.12](https://github.com/laurent22/joplin/releases/tag/v2.13.12) | 2023-12-31T16:08:02Z | 45,268 | 14,297 | 5,083 | 64,648 | | ||||
| | [v2.13.11](https://github.com/laurent22/joplin/releases/tag/v2.13.11) | 2023-12-24T12:58:53Z | 46,130 | 13,262 | 5,973 | 65,365 | | ||||
| | [v2.13.10](https://github.com/laurent22/joplin/releases/tag/v2.13.10) | 2023-12-22T10:11:08Z | 19,865 | 8,696 | 1,470 | 30,031 | | ||||
| | [v2.13.9](https://github.com/laurent22/joplin/releases/tag/v2.13.9) | 2023-12-09T17:18:58Z | 67,999 | 21,923 | 8,610 | 98,532 | | ||||
| | [v2.13.8](https://github.com/laurent22/joplin/releases/tag/v2.13.8) | 2023-12-03T12:07:08Z | 50,709 | 17,902 | 5,214 | 73,825 | | ||||
| | [v2.13.6](https://github.com/laurent22/joplin/releases/tag/v2.13.6) (p) | 2023-11-17T19:24:03Z | 2,157 | 446   | 573   | 3,176 | | ||||
| | [v2.13.5](https://github.com/laurent22/joplin/releases/tag/v2.13.5) (p) | 2023-11-09T20:24:09Z | 1,467 | 339   | 440   | 2,246 | | ||||
| | [v3.3.13](https://github.com/laurent22/joplin/releases/tag/v3.3.13) | 2025-06-09T20:13:30Z | 125,532 | 13,790 | 20,996 | 160,318 | | ||||
| | [v3.4.1](https://github.com/laurent22/joplin/releases/tag/v3.4.1) (p) | 2025-05-20T09:59:39Z | 3,318 | 888   | 1,043 | 5,249 | | ||||
| | [v3.3.12](https://github.com/laurent22/joplin/releases/tag/v3.3.12) | 2025-05-04T18:12:23Z | 156,635 | 21,490 | 28,133 | 206,258 | | ||||
| | [v3.3.10](https://github.com/laurent22/joplin/releases/tag/v3.3.10) | 2025-05-02T19:46:15Z | 24,855 | 5,075 | 1,808 | 31,738 | | ||||
| | [v3.3.9](https://github.com/laurent22/joplin/releases/tag/v3.3.9) | 2025-05-01T21:02:12Z | 23,518 | 6,090 | 1,174 | 30,782 | | ||||
| | [v3.3.7](https://github.com/laurent22/joplin/releases/tag/v3.3.7) (p) | 2025-04-29T13:47:19Z | 726   | 0     | 162   | 888   | | ||||
| | [v3.3.6](https://github.com/laurent22/joplin/releases/tag/v3.3.6) (p) | 2025-04-24T12:27:20Z | 1,030 | 264   | 242   | 1,536 | | ||||
| | [v3.3.5](https://github.com/laurent22/joplin/releases/tag/v3.3.5) (p) | 2025-04-17T13:40:31Z | 1,360 | 283   | 298   | 1,941 | | ||||
| | [v3.3.4](https://github.com/laurent22/joplin/releases/tag/v3.3.4) (p) | 2025-04-07T20:23:35Z | 1,598 | 390   | 364   | 2,352 | | ||||
| | [v3.3.3](https://github.com/laurent22/joplin/releases/tag/v3.3.3) (p) | 2025-03-16T11:52:33Z | 2,671 | 801   | 963   | 4,435 | | ||||
| | [v3.2.13](https://github.com/laurent22/joplin/releases/tag/v3.2.13) | 2025-02-28T14:38:21Z | 221,882 | 32,849 | 44,434 | 299,165 | | ||||
| | [v3.3.2](https://github.com/laurent22/joplin/releases/tag/v3.3.2) (p) | 2025-02-19T17:34:26Z | 2,334 | 560   | 646   | 3,540 | | ||||
| | [v3.3.1](https://github.com/laurent22/joplin/releases/tag/v3.3.1) (p) | 2025-02-16T17:06:26Z | 831   | 172   | 167   | 1,170 | | ||||
| | [v3.2.12](https://github.com/laurent22/joplin/releases/tag/v3.2.12) | 2025-01-23T23:52:04Z | 152,204 | 25,222 | 27,919 | 205,345 | | ||||
| | [v3.2.11](https://github.com/laurent22/joplin/releases/tag/v3.2.11) | 2025-01-13T17:48:21Z | 66,131 | 14,815 | 6,859 | 87,805 | | ||||
| | [v3.2.10](https://github.com/laurent22/joplin/releases/tag/v3.2.10) (p) | 2025-01-10T10:17:28Z | 1,428 | 162   | 185   | 1,775 | | ||||
| | [v3.2.9](https://github.com/laurent22/joplin/releases/tag/v3.2.9) (p) | 2025-01-09T22:58:42Z | 356   | 87    | 51    | 494   | | ||||
| | [v3.2.7](https://github.com/laurent22/joplin/releases/tag/v3.2.7) (p) | 2025-01-06T16:35:41Z | 863   | 152   | 841   | 1,856 | | ||||
| | [v3.2.6](https://github.com/laurent22/joplin/releases/tag/v3.2.6) (p) | 2024-12-23T21:54:40Z | 1,736 | 331   | 475   | 2,542 | | ||||
| | [v3.2.5](https://github.com/laurent22/joplin/releases/tag/v3.2.5) (p) | 2024-12-18T10:41:13Z | 992   | 195   | 219   | 1,406 | | ||||
| | [v3.2.4](https://github.com/laurent22/joplin/releases/tag/v3.2.4) (p) | 2024-12-12T17:59:52Z | 1,006 | 148   | 233   | 1,387 | | ||||
| | [v3.2.3](https://github.com/laurent22/joplin/releases/tag/v3.2.3) (p) | 2024-11-18T00:09:05Z | 2,690 | 551   | 889   | 4,130 | | ||||
| | [v3.2.1](https://github.com/laurent22/joplin/releases/tag/v3.2.1) (p) | 2024-11-10T16:16:27Z | 1,237 | 230   | 346   | 1,813 | | ||||
| | [v3.1.24](https://github.com/laurent22/joplin/releases/tag/v3.1.24) | 2024-11-09T15:08:29Z | 208,423 | 33,473 | 43,800 | 285,696 | | ||||
| | [v3.1.23](https://github.com/laurent22/joplin/releases/tag/v3.1.23) | 2024-11-07T10:56:45Z | 27,494 | 6,731 | 1,521 | 35,746 | | ||||
| | [v3.1.22](https://github.com/laurent22/joplin/releases/tag/v3.1.22) | 2024-11-05T08:59:32Z | 29,300 | 8,697 | 1,223 | 39,220 | | ||||
| | [v3.1.20](https://github.com/laurent22/joplin/releases/tag/v3.1.20) | 2024-10-22T12:21:32Z | 95,254 | 19,245 | 13,920 | 128,419 | | ||||
| | [v3.1.18](https://github.com/laurent22/joplin/releases/tag/v3.1.18) (p) | 2024-10-11T23:27:10Z | 1,507 | 280   | 588   | 2,375 | | ||||
| | [v3.1.17](https://github.com/laurent22/joplin/releases/tag/v3.1.17) (p) | 2024-09-26T11:57:54Z | 1,699 | 350   | 532   | 2,581 | | ||||
| | [v3.1.15](https://github.com/laurent22/joplin/releases/tag/v3.1.15) (p) | 2024-09-17T09:15:10Z | 1,180 | 224   | 494   | 1,898 | | ||||
| | [v3.1.8](https://github.com/laurent22/joplin/releases/tag/v3.1.8) (p) | 2024-09-08T20:32:44Z | 1,222 | 256   | 334   | 1,812 | | ||||
| | [v3.1.6](https://github.com/laurent22/joplin/releases/tag/v3.1.6) (p) | 2024-09-02T13:19:40Z | 963   | 229   | 426   | 1,618 | | ||||
| | [v3.1.4](https://github.com/laurent22/joplin/releases/tag/v3.1.4) (p) | 2024-08-27T17:46:38Z | 944   | 176   | 248   | 1,368 | | ||||
| | [v3.0.15](https://github.com/laurent22/joplin/releases/tag/v3.0.15) | 2024-08-21T09:19:58Z | 203,585 | 37,807 | 45,139 | 286,531 | | ||||
| | [v3.1.3](https://github.com/laurent22/joplin/releases/tag/v3.1.3) (p) | 2024-08-17T13:08:21Z | 1,235 | 283   | 486   | 2,004 | | ||||
| | [v3.1.2](https://github.com/laurent22/joplin/releases/tag/v3.1.2) (p) | 2024-08-16T09:00:59Z | 446   | 110   | 87    | 643   | | ||||
| | [v3.1.1](https://github.com/laurent22/joplin/releases/tag/v3.1.1) (p) | 2024-08-10T11:36:02Z | 1,082 | 203   | 264   | 1,549 | | ||||
| | [v2.14.23](https://github.com/laurent22/joplin/releases/tag/v2.14.23) | 2024-08-07T11:15:25Z | 10,792 | 2,647 | 624   | 14,063 | | ||||
| | [v3.0.14](https://github.com/laurent22/joplin/releases/tag/v3.0.14) | 2024-07-28T13:55:50Z | 89,640 | 18,571 | 18,826 | 127,037 | | ||||
| | [v3.0.12](https://github.com/laurent22/joplin/releases/tag/v3.0.12) | 2024-07-02T17:11:14Z | 44,593 | 12,832 | 7,336 | 64,761 | | ||||
| | [v3.0.11](https://github.com/laurent22/joplin/releases/tag/v3.0.11) (p) | 2024-06-29T10:20:02Z | 841   | 164   | 265   | 1,270 | | ||||
| | [v3.0.10](https://github.com/laurent22/joplin/releases/tag/v3.0.10) (p) | 2024-06-19T15:24:07Z | 1,618 | 291   | 559   | 2,468 | | ||||
| | [v3.0.9](https://github.com/laurent22/joplin/releases/tag/v3.0.9) (p) | 2024-06-12T19:07:50Z | 1,267 | 259   | 378   | 1,904 | | ||||
| | [v3.0.8](https://github.com/laurent22/joplin/releases/tag/v3.0.8) (p) | 2024-05-22T14:20:45Z | 2,660 | 0     | 912   | 3,572 | | ||||
| | [v2.14.22](https://github.com/laurent22/joplin/releases/tag/v2.14.22) | 2024-05-22T19:19:02Z | 143,224 | 30,785 | 25,477 | 199,486 | | ||||
| | [v3.0.6](https://github.com/laurent22/joplin/releases/tag/v3.0.6) (p) | 2024-04-27T13:16:04Z | 3,004 | 687   | 861   | 4,552 | | ||||
| | [v3.0.3](https://github.com/laurent22/joplin/releases/tag/v3.0.3) (p) | 2024-04-18T15:41:38Z | 1,483 | 320   | 331   | 2,134 | | ||||
| | [v3.0.2](https://github.com/laurent22/joplin/releases/tag/v3.0.2) (p) | 2024-03-21T18:18:49Z | 2,870 | 722   | 1,101 | 4,693 | | ||||
| | [v2.14.20](https://github.com/laurent22/joplin/releases/tag/v2.14.20) | 2024-03-18T17:05:17Z | 193,231 | 39,539 | 38,243 | 271,013 | | ||||
| | [v2.14.19](https://github.com/laurent22/joplin/releases/tag/v2.14.19) | 2024-03-08T10:45:16Z | 65,150 | 18,470 | 8,552 | 92,172 | | ||||
| | [v2.14.17](https://github.com/laurent22/joplin/releases/tag/v2.14.17) | 2024-03-01T18:10:26Z | 63,726 | 18,640 | 7,618 | 89,984 | | ||||
| | [v2.14.16](https://github.com/laurent22/joplin/releases/tag/v2.14.16) (p) | 2024-02-22T22:49:10Z | 1,349 | 284   | 378   | 2,011 | | ||||
| | [v2.14.15](https://github.com/laurent22/joplin/releases/tag/v2.14.15) (p) | 2024-02-19T11:24:57Z | 853   | 180   | 191   | 1,224 | | ||||
| | [v2.14.14](https://github.com/laurent22/joplin/releases/tag/v2.14.14) (p) | 2024-02-10T16:03:08Z | 1,272 | 243   | 379   | 1,894 | | ||||
| | [v2.14.13](https://github.com/laurent22/joplin/releases/tag/v2.14.13) (p) | 2024-02-09T16:31:54Z | 412   | 82    | 80    | 574   | | ||||
| | [v2.14.12](https://github.com/laurent22/joplin/releases/tag/v2.14.12) (p) | 2024-02-03T12:11:47Z | 980   | 218   | 247   | 1,445 | | ||||
| | [v2.14.11](https://github.com/laurent22/joplin/releases/tag/v2.14.11) (p) | 2024-01-26T11:53:05Z | 1,266 | 259   | 464   | 1,989 | | ||||
| | [v2.14.10](https://github.com/laurent22/joplin/releases/tag/v2.14.10) (p) | 2024-01-18T22:45:04Z | 2,087 | 269   | 371   | 2,727 | | ||||
| | [v2.13.15](https://github.com/laurent22/joplin/releases/tag/v2.13.15) | 2024-01-15T13:01:19Z | 150,122 | 34,151 | 29,262 | 213,535 | | ||||
| | [v2.13.14](https://github.com/laurent22/joplin/releases/tag/v2.13.14) | 2024-01-13T19:11:04Z | 20,115 | 7,827 | 2,363 | 30,305 | | ||||
| | [v2.14.9](https://github.com/laurent22/joplin/releases/tag/v2.14.9) (p) | 2024-01-11T22:17:59Z | 1,057 | 0     | 241   | 1,298 | | ||||
| | [v2.14.8](https://github.com/laurent22/joplin/releases/tag/v2.14.8) (p) | 2024-01-09T22:57:07Z | 757   | 237   | 174   | 1,168 | | ||||
| | [v2.14.7](https://github.com/laurent22/joplin/releases/tag/v2.14.7) (p) | 2024-01-08T11:51:49Z | 622   | 128   | 174   | 924   | | ||||
| | [v2.14.6](https://github.com/laurent22/joplin/releases/tag/v2.14.6) (p) | 2024-01-06T16:38:32Z | 728   | 150   | 151   | 1,029 | | ||||
| | [v2.13.13](https://github.com/laurent22/joplin/releases/tag/v2.13.13) | 2024-01-06T13:33:11Z | 54,202 | 15,934 | 6,346 | 76,482 | | ||||
| | [v2.13.12](https://github.com/laurent22/joplin/releases/tag/v2.13.12) | 2023-12-31T16:08:02Z | 45,490 | 14,298 | 5,084 | 64,872 | | ||||
| | [v2.13.11](https://github.com/laurent22/joplin/releases/tag/v2.13.11) | 2023-12-24T12:58:53Z | 46,271 | 13,263 | 5,974 | 65,508 | | ||||
| | [v2.13.10](https://github.com/laurent22/joplin/releases/tag/v2.13.10) | 2023-12-22T10:11:08Z | 19,973 | 8,697 | 1,477 | 30,147 | | ||||
| | [v2.13.9](https://github.com/laurent22/joplin/releases/tag/v2.13.9) | 2023-12-09T17:18:58Z | 68,224 | 21,924 | 8,611 | 98,759 | | ||||
| | [v2.13.8](https://github.com/laurent22/joplin/releases/tag/v2.13.8) | 2023-12-03T12:07:08Z | 50,889 | 17,904 | 5,216 | 74,009 | | ||||
| | [v2.13.6](https://github.com/laurent22/joplin/releases/tag/v2.13.6) (p) | 2023-11-17T19:24:03Z | 2,159 | 446   | 576   | 3,181 | | ||||
| | [v2.13.5](https://github.com/laurent22/joplin/releases/tag/v2.13.5) (p) | 2023-11-09T20:24:09Z | 1,469 | 340   | 440   | 2,249 | | ||||
| | [v2.13.4](https://github.com/laurent22/joplin/releases/tag/v2.13.4) (p) | 2023-10-31T00:01:00Z | 1,540 | 372   | 485   | 2,397 | | ||||
| | [v2.13.3](https://github.com/laurent22/joplin/releases/tag/v2.13.3) (p) | 2023-10-24T09:25:33Z | 1,303 | 281   | 301   | 1,885 | | ||||
| | [v2.12.19](https://github.com/laurent22/joplin/releases/tag/v2.12.19) | 2023-10-21T09:39:18Z | 166,838 | 43,656 | 27,867 | 238,361 | | ||||
| | [v2.13.3](https://github.com/laurent22/joplin/releases/tag/v2.13.3) (p) | 2023-10-24T09:25:33Z | 1,303 | 281   | 302   | 1,886 | | ||||
| | [v2.12.19](https://github.com/laurent22/joplin/releases/tag/v2.12.19) | 2023-10-21T09:39:18Z | 167,229 | 43,659 | 27,880 | 238,768 | | ||||
| | [v2.13.2](https://github.com/laurent22/joplin/releases/tag/v2.13.2) (p) | 2023-10-06T17:00:07Z | 2,034 | 503   | 704   | 3,241 | | ||||
| | [v2.12.18](https://github.com/laurent22/joplin/releases/tag/v2.12.18) | 2023-09-22T14:37:24Z | 109,047 | 36,516 | 18,706 | 164,269 | | ||||
| | [v2.12.17](https://github.com/laurent22/joplin/releases/tag/v2.12.17) | 2023-09-14T21:54:52Z | 47,458 | 21,032 | 6,632 | 75,122 | | ||||
| | [v2.12.18](https://github.com/laurent22/joplin/releases/tag/v2.12.18) | 2023-09-22T14:37:24Z | 109,356 | 36,520 | 18,709 | 164,585 | | ||||
| | [v2.12.17](https://github.com/laurent22/joplin/releases/tag/v2.12.17) | 2023-09-14T21:54:52Z | 47,687 | 21,032 | 6,633 | 75,352 | | ||||
| | [v2.13.1](https://github.com/laurent22/joplin/releases/tag/v2.13.1) (p) | 2023-09-13T09:31:50Z | 1,390 | 427   | 667   | 2,484 | | ||||
| | [v2.12.16](https://github.com/laurent22/joplin/releases/tag/v2.12.16) | 2023-09-11T22:33:37Z | 28,383 | 14,668 | 2,452 | 45,503 | | ||||
| | [v2.12.15](https://github.com/laurent22/joplin/releases/tag/v2.12.15) | 2023-08-27T11:35:39Z | 64,640 | 28,088 | 8,402 | 101,130 | | ||||
| | [v2.12.12](https://github.com/laurent22/joplin/releases/tag/v2.12.12) (p) | 2023-08-19T22:44:56Z | 2,888 | 387   | 428   | 3,703 | | ||||
| | [v2.12.10](https://github.com/laurent22/joplin/releases/tag/v2.12.10) (p) | 2023-07-30T18:25:58Z | 7,520 | 3,817 | 914   | 12,251 | | ||||
| | [v2.12.9](https://github.com/laurent22/joplin/releases/tag/v2.12.9) (p) | 2023-07-25T16:06:08Z | 2,352 | 369   | 321   | 3,042 | | ||||
| | [v2.12.7](https://github.com/laurent22/joplin/releases/tag/v2.12.7) (p) | 2023-07-13T12:55:31Z | 2,165 | 664   | 591   | 3,420 | | ||||
| | [v2.12.5](https://github.com/laurent22/joplin/releases/tag/v2.12.5) (p) | 2023-07-12T15:03:46Z | 1,905 | 164   | 151   | 2,220 | | ||||
| | [v2.12.4](https://github.com/laurent22/joplin/releases/tag/v2.12.4) (p) | 2023-07-07T22:36:53Z | 1,064 | 437   | 217   | 1,718 | | ||||
| | [v2.12.3](https://github.com/laurent22/joplin/releases/tag/v2.12.3) (p) | 2023-07-07T10:16:55Z | 394   | 192   | 95    | 681   | | ||||
| | [v2.11.11](https://github.com/laurent22/joplin/releases/tag/v2.11.11) | 2023-06-23T15:16:37Z | 189,480 | 67,230 | 38,865 | 295,575 | | ||||
| | [v2.11.9](https://github.com/laurent22/joplin/releases/tag/v2.11.9) (p) | 2023-06-06T16:23:27Z | 2,303 | 574   | 746   | 3,623 | | ||||
| | [v2.11.6](https://github.com/laurent22/joplin/releases/tag/v2.11.6) (p) | 2023-05-31T20:13:08Z | 1,162 | 435   | 345   | 1,942 | | ||||
| | [v2.11.5](https://github.com/laurent22/joplin/releases/tag/v2.11.5) (p) | 2023-05-28T00:41:40Z | 1,029 | 307   | 282   | 1,618 | | ||||
| | [v2.10.19](https://github.com/laurent22/joplin/releases/tag/v2.10.19) | 2023-05-17T12:25:41Z | 124,680 | 48,335 | 22,477 | 195,492 | | ||||
| | [v2.11.4](https://github.com/laurent22/joplin/releases/tag/v2.11.4) (p) | 2023-05-16T10:02:21Z | 1,083 | 468   | 415   | 1,966 | | ||||
| | [v2.11.3](https://github.com/laurent22/joplin/releases/tag/v2.11.3) (p) | 2023-05-16T09:09:57Z | 138   | 40    | 40    | 218   | | ||||
| | [v2.10.18](https://github.com/laurent22/joplin/releases/tag/v2.10.18) | 2023-05-09T13:27:43Z | 56,526 | 24,257 | 6,791 | 87,574 | | ||||
| | [v2.10.17](https://github.com/laurent22/joplin/releases/tag/v2.10.17) | 2023-05-08T17:27:28Z | 19,229 | 11,508 | 887   | 31,624 | | ||||
| | [v2.10.16](https://github.com/laurent22/joplin/releases/tag/v2.10.16) | 2023-04-27T09:27:45Z | 9,488 | 4,257 | 780   | 14,525 | | ||||
| | [v2.10.15](https://github.com/laurent22/joplin/releases/tag/v2.10.15) (p) | 2023-04-26T22:02:16Z | 374   | 141   | 59    | 574   | | ||||
| | [v2.10.13](https://github.com/laurent22/joplin/releases/tag/v2.10.13) (p) | 2023-04-03T16:53:46Z | 4,659 | 829   | 1,078 | 6,566 | | ||||
| | [v2.10.12](https://github.com/laurent22/joplin/releases/tag/v2.10.12) (p) | 2023-03-23T12:17:13Z | 3,625 | 519   | 604   | 4,748 | | ||||
| | [v2.10.11](https://github.com/laurent22/joplin/releases/tag/v2.10.11) (p) | 2023-03-17T10:54:02Z | 2,995 | 383   | 393   | 3,771 | | ||||
| | [v2.10.10](https://github.com/laurent22/joplin/releases/tag/v2.10.10) (p) | 2023-03-13T23:16:37Z | 2,501 | 284   | 255   | 3,040 | | ||||
| | [v2.10.9](https://github.com/laurent22/joplin/releases/tag/v2.10.9) (p) | 2023-03-12T16:16:45Z | 2,035 | 214   | 297   | 2,546 | | ||||
| | [v2.10.8](https://github.com/laurent22/joplin/releases/tag/v2.10.8) (p) | 2023-02-26T12:53:55Z | 4,517 | 573   | 872   | 5,962 | | ||||
| | [v2.10.7](https://github.com/laurent22/joplin/releases/tag/v2.10.7) (p) | 2023-02-24T10:56:20Z | 2,187 | 191   | 280   | 2,658 | | ||||
| | [v2.10.6](https://github.com/laurent22/joplin/releases/tag/v2.10.6) (p) | 2023-02-20T14:00:05Z | 2,978 | 342   | 290   | 3,610 | | ||||
| | [v2.12.16](https://github.com/laurent22/joplin/releases/tag/v2.12.16) | 2023-09-11T22:33:37Z | 28,478 | 14,668 | 2,452 | 45,598 | | ||||
| | [v2.12.15](https://github.com/laurent22/joplin/releases/tag/v2.12.15) | 2023-08-27T11:35:39Z | 64,835 | 28,100 | 8,417 | 101,352 | | ||||
| | [v2.12.12](https://github.com/laurent22/joplin/releases/tag/v2.12.12) (p) | 2023-08-19T22:44:56Z | 3,005 | 387   | 429   | 3,821 | | ||||
| | [v2.12.10](https://github.com/laurent22/joplin/releases/tag/v2.12.10) (p) | 2023-07-30T18:25:58Z | 7,631 | 3,817 | 914   | 12,362 | | ||||
| | [v2.12.9](https://github.com/laurent22/joplin/releases/tag/v2.12.9) (p) | 2023-07-25T16:06:08Z | 2,457 | 369   | 322   | 3,148 | | ||||
| | [v2.12.7](https://github.com/laurent22/joplin/releases/tag/v2.12.7) (p) | 2023-07-13T12:55:31Z | 2,172 | 664   | 593   | 3,429 | | ||||
| | [v2.12.5](https://github.com/laurent22/joplin/releases/tag/v2.12.5) (p) | 2023-07-12T15:03:46Z | 1,931 | 165   | 156   | 2,252 | | ||||
| | [v2.12.4](https://github.com/laurent22/joplin/releases/tag/v2.12.4) (p) | 2023-07-07T22:36:53Z | 1,071 | 448   | 220   | 1,739 | | ||||
| | [v2.12.3](https://github.com/laurent22/joplin/releases/tag/v2.12.3) (p) | 2023-07-07T10:16:55Z | 401   | 202   | 99    | 702   | | ||||
| | [v2.11.11](https://github.com/laurent22/joplin/releases/tag/v2.11.11) | 2023-06-23T15:16:37Z | 189,759 | 67,237 | 38,874 | 295,870 | | ||||
| | [v2.11.9](https://github.com/laurent22/joplin/releases/tag/v2.11.9) (p) | 2023-06-06T16:23:27Z | 2,310 | 578   | 749   | 3,637 | | ||||
| | [v2.11.6](https://github.com/laurent22/joplin/releases/tag/v2.11.6) (p) | 2023-05-31T20:13:08Z | 1,173 | 439   | 348   | 1,960 | | ||||
| | [v2.11.5](https://github.com/laurent22/joplin/releases/tag/v2.11.5) (p) | 2023-05-28T00:41:40Z | 1,035 | 311   | 286   | 1,632 | | ||||
| | [v2.10.19](https://github.com/laurent22/joplin/releases/tag/v2.10.19) | 2023-05-17T12:25:41Z | 124,926 | 48,338 | 22,482 | 195,746 | | ||||
| | [v2.11.4](https://github.com/laurent22/joplin/releases/tag/v2.11.4) (p) | 2023-05-16T10:02:21Z | 1,089 | 472   | 419   | 1,980 | | ||||
| | [v2.11.3](https://github.com/laurent22/joplin/releases/tag/v2.11.3) (p) | 2023-05-16T09:09:57Z | 146   | 44    | 43    | 233   | | ||||
| | [v2.10.18](https://github.com/laurent22/joplin/releases/tag/v2.10.18) | 2023-05-09T13:27:43Z | 56,717 | 24,261 | 6,801 | 87,779 | | ||||
| | [v2.10.17](https://github.com/laurent22/joplin/releases/tag/v2.10.17) | 2023-05-08T17:27:28Z | 19,372 | 11,512 | 892   | 31,776 | | ||||
| | [v2.10.16](https://github.com/laurent22/joplin/releases/tag/v2.10.16) | 2023-04-27T09:27:45Z | 9,680 | 4,260 | 784   | 14,724 | | ||||
| | [v2.10.15](https://github.com/laurent22/joplin/releases/tag/v2.10.15) (p) | 2023-04-26T22:02:16Z | 381   | 145   | 62    | 588   | | ||||
| | [v2.10.13](https://github.com/laurent22/joplin/releases/tag/v2.10.13) (p) | 2023-04-03T16:53:46Z | 4,764 | 829   | 1,078 | 6,671 | | ||||
| | [v2.10.12](https://github.com/laurent22/joplin/releases/tag/v2.10.12) (p) | 2023-03-23T12:17:13Z | 3,734 | 519   | 604   | 4,857 | | ||||
| | [v2.10.11](https://github.com/laurent22/joplin/releases/tag/v2.10.11) (p) | 2023-03-17T10:54:02Z | 3,103 | 383   | 393   | 3,879 | | ||||
| | [v2.10.10](https://github.com/laurent22/joplin/releases/tag/v2.10.10) (p) | 2023-03-13T23:16:37Z | 2,608 | 284   | 256   | 3,148 | | ||||
| | [v2.10.9](https://github.com/laurent22/joplin/releases/tag/v2.10.9) (p) | 2023-03-12T16:16:45Z | 2,146 | 214   | 297   | 2,657 | | ||||
| | [v2.10.8](https://github.com/laurent22/joplin/releases/tag/v2.10.8) (p) | 2023-02-26T12:53:55Z | 4,628 | 573   | 872   | 6,073 | | ||||
| | [v2.10.7](https://github.com/laurent22/joplin/releases/tag/v2.10.7) (p) | 2023-02-24T10:56:20Z | 2,295 | 191   | 280   | 2,766 | | ||||
| | [v2.10.6](https://github.com/laurent22/joplin/releases/tag/v2.10.6) (p) | 2023-02-20T14:00:05Z | 3,084 | 342   | 290   | 3,716 | | ||||
| | [v2.10.5](https://github.com/laurent22/joplin/releases/tag/v2.10.5) | 2023-01-16T15:00:53Z | 367   | 103   | 309   | 779   | | ||||
| | [v2.10.4](https://github.com/laurent22/joplin/releases/tag/v2.10.4) (p) | 2023-01-05T13:09:20Z | 8,201 | 1,303 | 1,812 | 11,316 | | ||||
| | [v2.10.3](https://github.com/laurent22/joplin/releases/tag/v2.10.3) (p) | 2022-12-31T15:53:23Z | 2,763 | 314   | 417   | 3,494 | | ||||
| | [v2.10.2](https://github.com/laurent22/joplin/releases/tag/v2.10.2) (p) | 2022-12-18T18:05:08Z | 4,191 | 591   | 639   | 5,421 | | ||||
| | [v2.9.17](https://github.com/laurent22/joplin/releases/tag/v2.9.17) | 2022-11-15T10:28:37Z | 335,646 | 108,788 | 83,358 | 527,792 | | ||||
| | [v2.9.12](https://github.com/laurent22/joplin/releases/tag/v2.9.12) (p) | 2022-11-01T17:06:05Z | 11,379 | 612   | 545   | 12,536 | | ||||
| | [v2.9.11](https://github.com/laurent22/joplin/releases/tag/v2.9.11) (p) | 2022-10-23T16:09:58Z | 3,528 | 531   | 762   | 4,821 | | ||||
| | [v2.9.4](https://github.com/laurent22/joplin/releases/tag/v2.9.4) (p) | 2022-08-18T16:52:26Z | 8,578 | 1,868 | 2,200 | 12,646 | | ||||
| | [v2.10.4](https://github.com/laurent22/joplin/releases/tag/v2.10.4) (p) | 2023-01-05T13:09:20Z | 8,305 | 1,303 | 1,812 | 11,420 | | ||||
| | [v2.10.3](https://github.com/laurent22/joplin/releases/tag/v2.10.3) (p) | 2022-12-31T15:53:23Z | 2,866 | 314   | 417   | 3,597 | | ||||
| | [v2.10.2](https://github.com/laurent22/joplin/releases/tag/v2.10.2) (p) | 2022-12-18T18:05:08Z | 4,296 | 591   | 639   | 5,526 | | ||||
| | [v2.9.17](https://github.com/laurent22/joplin/releases/tag/v2.9.17) | 2022-11-15T10:28:37Z | 335,842 | 108,794 | 83,364 | 528,000 | | ||||
| | [v2.9.12](https://github.com/laurent22/joplin/releases/tag/v2.9.12) (p) | 2022-11-01T17:06:05Z | 11,482 | 612   | 546   | 12,640 | | ||||
| | [v2.9.11](https://github.com/laurent22/joplin/releases/tag/v2.9.11) (p) | 2022-10-23T16:09:58Z | 3,634 | 531   | 762   | 4,927 | | ||||
| | [v2.9.4](https://github.com/laurent22/joplin/releases/tag/v2.9.4) (p) | 2022-08-18T16:52:26Z | 8,687 | 1,868 | 2,201 | 12,756 | | ||||
| | [v2.9.3](https://github.com/laurent22/joplin/releases/tag/v2.9.3) (p) | 2022-08-18T13:11:09Z | 363   | 92    | 275   | 730   | | ||||
| | [v2.9.2](https://github.com/laurent22/joplin/releases/tag/v2.9.2) (p) | 2022-08-12T18:12:12Z | 1,533 | 447   | 0     | 1,980 | | ||||
| | [v2.9.1](https://github.com/laurent22/joplin/releases/tag/v2.9.1) (p) | 2022-07-11T09:59:32Z | 8,011 | 1,343 | 1,412 | 10,766 | | ||||
| | [v2.8.8](https://github.com/laurent22/joplin/releases/tag/v2.8.8) | 2022-05-17T14:48:06Z | 351,763 | 114,379 | 113,587 | 579,729 | | ||||
| | [v2.8.7](https://github.com/laurent22/joplin/releases/tag/v2.8.7) (p) | 2022-05-06T11:34:27Z | 4,513 | 366   | 429   | 5,308 | | ||||
| | [v2.8.6](https://github.com/laurent22/joplin/releases/tag/v2.8.6) (p) | 2022-05-03T10:08:25Z | 4,143 | 403   | 332   | 4,878 | | ||||
| | [v2.8.5](https://github.com/laurent22/joplin/releases/tag/v2.8.5) (p) | 2022-04-27T13:51:50Z | 4,236 | 370   | 357   | 4,963 | | ||||
| | [v2.8.4](https://github.com/laurent22/joplin/releases/tag/v2.8.4) (p) | 2022-04-19T18:00:09Z | 4,718 | 590   | 334   | 5,642 | | ||||
| | [v2.8.2](https://github.com/laurent22/joplin/releases/tag/v2.8.2) (p) | 2022-04-14T11:35:45Z | 4,136 | 280   | 282   | 4,698 | | ||||
| | [v2.7.15](https://github.com/laurent22/joplin/releases/tag/v2.7.15) | 2022-03-17T13:03:23Z | 156,446 | 56,783 | 51,285 | 264,514 | | ||||
| | [v2.7.14](https://github.com/laurent22/joplin/releases/tag/v2.7.14) | 2022-02-27T11:30:53Z | 34,701 | 16,783 | 4,811 | 56,295 | | ||||
| | [v2.7.13](https://github.com/laurent22/joplin/releases/tag/v2.7.13) | 2022-02-24T17:42:12Z | 55,125 | 25,726 | 11,724 | 92,575 | | ||||
| | [v2.7.12](https://github.com/laurent22/joplin/releases/tag/v2.7.12) (p) | 2022-02-14T15:06:14Z | 4,907 | 465   | 504   | 5,876 | | ||||
| | [v2.7.11](https://github.com/laurent22/joplin/releases/tag/v2.7.11) (p) | 2022-02-12T13:00:02Z | 4,052 | 195   | 174   | 4,421 | | ||||
| | [v2.7.10](https://github.com/laurent22/joplin/releases/tag/v2.7.10) (p) | 2022-02-11T18:19:09Z | 3,563 | 126   | 95    | 3,784 | | ||||
| | [v2.7.8](https://github.com/laurent22/joplin/releases/tag/v2.7.8) (p) | 2022-01-19T09:35:27Z | 5,786 | 771   | 830   | 7,387 | | ||||
| | [v2.7.7](https://github.com/laurent22/joplin/releases/tag/v2.7.7) (p) | 2022-01-18T14:05:07Z | 3,881 | 157   | 144   | 4,182 | | ||||
| | [v2.7.6](https://github.com/laurent22/joplin/releases/tag/v2.7.6) (p) | 2022-01-17T17:08:28Z | 3,809 | 184   | 121   | 4,114 | | ||||
| | [v2.6.10](https://github.com/laurent22/joplin/releases/tag/v2.6.10) | 2021-12-19T11:31:16Z | 136,462 | 51,206 | 49,319 | 236,987 | | ||||
| | [v2.6.9](https://github.com/laurent22/joplin/releases/tag/v2.6.9) | 2021-12-17T11:57:32Z | 19,441 | 9,500 | 3,194 | 32,135 | | ||||
| | [v2.6.7](https://github.com/laurent22/joplin/releases/tag/v2.6.7) (p) | 2021-12-16T10:47:23Z | 4,025 | 180   | 112   | 4,317 | | ||||
| | [v2.6.6](https://github.com/laurent22/joplin/releases/tag/v2.6.6) (p) | 2021-12-13T12:31:43Z | 4,020 | 257   | 174   | 4,451 | | ||||
| | [v2.6.5](https://github.com/laurent22/joplin/releases/tag/v2.6.5) (p) | 2021-12-13T10:07:04Z | 3,355 | 51    | 36    | 3,442 | | ||||
| | [v2.6.4](https://github.com/laurent22/joplin/releases/tag/v2.6.4) (p) | 2021-12-09T19:53:43Z | 4,091 | 291   | 207   | 4,589 | | ||||
| | [v2.6.2](https://github.com/laurent22/joplin/releases/tag/v2.6.2) (p) | 2021-11-18T12:19:12Z | 5,892 | 793   | 702   | 7,387 | | ||||
| | [v2.5.12](https://github.com/laurent22/joplin/releases/tag/v2.5.12) | 2021-11-08T11:07:11Z | 83,180 | 32,507 | 25,236 | 140,923 | | ||||
| | [v2.5.10](https://github.com/laurent22/joplin/releases/tag/v2.5.10) | 2021-11-01T08:22:42Z | 47,339 | 19,050 | 10,098 | 76,487 | | ||||
| | [v2.5.8](https://github.com/laurent22/joplin/releases/tag/v2.5.8) | 2021-10-31T11:38:03Z | 16,061 | 6,581 | 2,329 | 24,971 | | ||||
| | [v2.5.7](https://github.com/laurent22/joplin/releases/tag/v2.5.7) (p) | 2021-10-29T14:47:33Z | 3,624 | 203   | 164   | 3,991 | | ||||
| | [v2.5.6](https://github.com/laurent22/joplin/releases/tag/v2.5.6) (p) | 2021-10-28T22:03:09Z | 3,676 | 178   | 108   | 3,962 | | ||||
| | [v2.5.4](https://github.com/laurent22/joplin/releases/tag/v2.5.4) (p) | 2021-10-19T10:10:54Z | 5,168 | 568   | 580   | 6,316 | | ||||
| | [v2.4.12](https://github.com/laurent22/joplin/releases/tag/v2.4.12) | 2021-10-13T17:24:34Z | 47,788 | 19,984 | 9,789 | 77,561 | | ||||
| | [v2.5.1](https://github.com/laurent22/joplin/releases/tag/v2.5.1) (p) | 2021-10-02T09:51:58Z | 6,229 | 907   | 947   | 8,083 | | ||||
| | [v2.4.9](https://github.com/laurent22/joplin/releases/tag/v2.4.9) | 2021-09-29T19:08:58Z | 59,488 | 23,262 | 15,914 | 98,664 | | ||||
| | [v2.4.8](https://github.com/laurent22/joplin/releases/tag/v2.4.8) (p) | 2021-09-22T19:01:46Z | 10,020 | 1,774 | 535   | 12,329 | | ||||
| | [v2.4.7](https://github.com/laurent22/joplin/releases/tag/v2.4.7) (p) | 2021-09-19T12:53:22Z | 4,149 | 259   | 210   | 4,618 | | ||||
| | [v2.4.6](https://github.com/laurent22/joplin/releases/tag/v2.4.6) (p) | 2021-09-09T18:57:17Z | 4,913 | 460   | 520   | 5,893 | | ||||
| | [v2.4.5](https://github.com/laurent22/joplin/releases/tag/v2.4.5) (p) | 2021-09-06T18:03:28Z | 4,157 | 275   | 225   | 4,657 | | ||||
| | [v2.4.4](https://github.com/laurent22/joplin/releases/tag/v2.4.4) (p) | 2021-08-30T16:02:51Z | 4,371 | 380   | 373   | 5,124 | | ||||
| | [v2.4.3](https://github.com/laurent22/joplin/releases/tag/v2.4.3) (p) | 2021-08-28T15:27:32Z | 3,858 | 207   | 180   | 4,245 | | ||||
| | [v2.4.2](https://github.com/laurent22/joplin/releases/tag/v2.4.2) (p) | 2021-08-27T17:13:21Z | 3,634 | 151   | 91    | 3,876 | | ||||
| | [v2.4.1](https://github.com/laurent22/joplin/releases/tag/v2.4.1) (p) | 2021-08-21T11:52:30Z | 4,472 | 372   | 337   | 5,181 | | ||||
| | [v2.3.5](https://github.com/laurent22/joplin/releases/tag/v2.3.5) | 2021-08-17T06:43:30Z | 85,517 | 31,439 | 33,144 | 150,100 | | ||||
| | [v2.3.3](https://github.com/laurent22/joplin/releases/tag/v2.3.3) | 2021-08-14T09:19:40Z | 18,702 | 6,886 | 4,062 | 29,650 | | ||||
| | [v2.2.7](https://github.com/laurent22/joplin/releases/tag/v2.2.7) | 2021-08-11T11:03:26Z | 18,568 | 7,524 | 2,607 | 28,699 | | ||||
| | [v2.2.6](https://github.com/laurent22/joplin/releases/tag/v2.2.6) (p) | 2021-08-09T19:29:20Z | 10,896 | 4,619 | 958   | 16,473 | | ||||
| | [v2.2.5](https://github.com/laurent22/joplin/releases/tag/v2.2.5) (p) | 2021-08-07T10:35:24Z | 4,206 | 278   | 208   | 4,692 | | ||||
| | [v2.2.4](https://github.com/laurent22/joplin/releases/tag/v2.2.4) (p) | 2021-08-05T16:42:48Z | 3,862 | 208   | 133   | 4,203 | | ||||
| | [v2.2.2](https://github.com/laurent22/joplin/releases/tag/v2.2.2) (p) | 2021-07-19T10:28:35Z | 5,785 | 737   | 648   | 7,170 | | ||||
| | [v2.1.9](https://github.com/laurent22/joplin/releases/tag/v2.1.9) | 2021-07-19T10:28:43Z | 50,195 | 18,962 | 16,821 | 85,978 | | ||||
| | [v2.2.1](https://github.com/laurent22/joplin/releases/tag/v2.2.1) (p) | 2021-07-09T17:38:25Z | 5,409 | 417   | 394   | 6,220 | | ||||
| | [v2.1.8](https://github.com/laurent22/joplin/releases/tag/v2.1.8) | 2021-07-03T08:25:16Z | 33,677 | 12,206 | 12,736 | 58,619 | | ||||
| | [v2.1.7](https://github.com/laurent22/joplin/releases/tag/v2.1.7) | 2021-06-26T19:48:55Z | 16,985 | 6,410 | 3,638 | 27,033 | | ||||
| | [v2.1.5](https://github.com/laurent22/joplin/releases/tag/v2.1.5) (p) | 2021-06-23T15:08:52Z | 4,306 | 254   | 202   | 4,762 | | ||||
| | [v2.1.3](https://github.com/laurent22/joplin/releases/tag/v2.1.3) (p) | 2021-06-19T16:32:51Z | 4,373 | 312   | 217   | 4,902 | | ||||
| | [v2.0.11](https://github.com/laurent22/joplin/releases/tag/v2.0.11) | 2021-06-16T17:55:49Z | 26,740 | 9,281 | 9,905 | 45,926 | | ||||
| | [v2.0.10](https://github.com/laurent22/joplin/releases/tag/v2.0.10) | 2021-06-16T07:58:29Z | 5,918 | 946   | 399   | 7,263 | | ||||
| | [v2.0.9](https://github.com/laurent22/joplin/releases/tag/v2.0.9) (p) | 2021-06-12T09:30:30Z | 4,360 | 307   | 899   | 5,566 | | ||||
| | [v2.0.8](https://github.com/laurent22/joplin/releases/tag/v2.0.8) (p) | 2021-06-10T16:15:08Z | 3,797 | 246   | 599   | 4,642 | | ||||
| | [v2.9.1](https://github.com/laurent22/joplin/releases/tag/v2.9.1) (p) | 2022-07-11T09:59:32Z | 8,123 | 1,343 | 1,413 | 10,879 | | ||||
| | [v2.8.8](https://github.com/laurent22/joplin/releases/tag/v2.8.8) | 2022-05-17T14:48:06Z | 351,940 | 114,392 | 113,590 | 579,922 | | ||||
| | [v2.8.7](https://github.com/laurent22/joplin/releases/tag/v2.8.7) (p) | 2022-05-06T11:34:27Z | 4,623 | 366   | 430   | 5,419 | | ||||
| | [v2.8.6](https://github.com/laurent22/joplin/releases/tag/v2.8.6) (p) | 2022-05-03T10:08:25Z | 4,254 | 403   | 333   | 4,990 | | ||||
| | [v2.8.5](https://github.com/laurent22/joplin/releases/tag/v2.8.5) (p) | 2022-04-27T13:51:50Z | 4,345 | 370   | 357   | 5,072 | | ||||
| | [v2.8.4](https://github.com/laurent22/joplin/releases/tag/v2.8.4) (p) | 2022-04-19T18:00:09Z | 4,823 | 590   | 334   | 5,747 | | ||||
| | [v2.8.2](https://github.com/laurent22/joplin/releases/tag/v2.8.2) (p) | 2022-04-14T11:35:45Z | 4,247 | 280   | 282   | 4,809 | | ||||
| | [v2.7.15](https://github.com/laurent22/joplin/releases/tag/v2.7.15) | 2022-03-17T13:03:23Z | 156,564 | 56,784 | 51,287 | 264,635 | | ||||
| | [v2.7.14](https://github.com/laurent22/joplin/releases/tag/v2.7.14) | 2022-02-27T11:30:53Z | 34,807 | 16,783 | 4,813 | 56,403 | | ||||
| | [v2.7.13](https://github.com/laurent22/joplin/releases/tag/v2.7.13) | 2022-02-24T17:42:12Z | 55,244 | 25,726 | 11,724 | 92,694 | | ||||
| | [v2.7.12](https://github.com/laurent22/joplin/releases/tag/v2.7.12) (p) | 2022-02-14T15:06:14Z | 5,011 | 465   | 505   | 5,981 | | ||||
| | [v2.7.11](https://github.com/laurent22/joplin/releases/tag/v2.7.11) (p) | 2022-02-12T13:00:02Z | 4,167 | 195   | 174   | 4,536 | | ||||
| | [v2.7.10](https://github.com/laurent22/joplin/releases/tag/v2.7.10) (p) | 2022-02-11T18:19:09Z | 3,678 | 126   | 95    | 3,899 | | ||||
| | [v2.7.8](https://github.com/laurent22/joplin/releases/tag/v2.7.8) (p) | 2022-01-19T09:35:27Z | 5,899 | 771   | 830   | 7,500 | | ||||
| | [v2.7.7](https://github.com/laurent22/joplin/releases/tag/v2.7.7) (p) | 2022-01-18T14:05:07Z | 3,989 | 157   | 144   | 4,290 | | ||||
| | [v2.7.6](https://github.com/laurent22/joplin/releases/tag/v2.7.6) (p) | 2022-01-17T17:08:28Z | 3,917 | 184   | 121   | 4,222 | | ||||
| | [v2.6.10](https://github.com/laurent22/joplin/releases/tag/v2.6.10) | 2021-12-19T11:31:16Z | 136,611 | 51,208 | 49,320 | 237,139 | | ||||
| | [v2.6.9](https://github.com/laurent22/joplin/releases/tag/v2.6.9) | 2021-12-17T11:57:32Z | 19,558 | 9,502 | 3,194 | 32,254 | | ||||
| | [v2.6.7](https://github.com/laurent22/joplin/releases/tag/v2.6.7) (p) | 2021-12-16T10:47:23Z | 4,136 | 180   | 112   | 4,428 | | ||||
| | [v2.6.6](https://github.com/laurent22/joplin/releases/tag/v2.6.6) (p) | 2021-12-13T12:31:43Z | 4,135 | 257   | 174   | 4,566 | | ||||
| | [v2.6.5](https://github.com/laurent22/joplin/releases/tag/v2.6.5) (p) | 2021-12-13T10:07:04Z | 3,462 | 51    | 36    | 3,549 | | ||||
| | [v2.6.4](https://github.com/laurent22/joplin/releases/tag/v2.6.4) (p) | 2021-12-09T19:53:43Z | 4,200 | 291   | 207   | 4,698 | | ||||
| | [v2.6.2](https://github.com/laurent22/joplin/releases/tag/v2.6.2) (p) | 2021-11-18T12:19:12Z | 5,997 | 793   | 702   | 7,492 | | ||||
| | [v2.5.12](https://github.com/laurent22/joplin/releases/tag/v2.5.12) | 2021-11-08T11:07:11Z | 83,335 | 32,507 | 25,236 | 141,078 | | ||||
| | [v2.5.10](https://github.com/laurent22/joplin/releases/tag/v2.5.10) | 2021-11-01T08:22:42Z | 47,459 | 19,051 | 10,098 | 76,608 | | ||||
| | [v2.5.8](https://github.com/laurent22/joplin/releases/tag/v2.5.8) | 2021-10-31T11:38:03Z | 16,182 | 6,581 | 2,332 | 25,095 | | ||||
| | [v2.5.7](https://github.com/laurent22/joplin/releases/tag/v2.5.7) (p) | 2021-10-29T14:47:33Z | 3,731 | 203   | 164   | 4,098 | | ||||
| | [v2.5.6](https://github.com/laurent22/joplin/releases/tag/v2.5.6) (p) | 2021-10-28T22:03:09Z | 3,786 | 178   | 108   | 4,072 | | ||||
| | [v2.5.4](https://github.com/laurent22/joplin/releases/tag/v2.5.4) (p) | 2021-10-19T10:10:54Z | 5,278 | 568   | 580   | 6,426 | | ||||
| | [v2.4.12](https://github.com/laurent22/joplin/releases/tag/v2.4.12) | 2021-10-13T17:24:34Z | 47,915 | 19,984 | 9,789 | 77,688 | | ||||
| | [v2.5.1](https://github.com/laurent22/joplin/releases/tag/v2.5.1) (p) | 2021-10-02T09:51:58Z | 6,335 | 907   | 947   | 8,189 | | ||||
| | [v2.4.9](https://github.com/laurent22/joplin/releases/tag/v2.4.9) | 2021-09-29T19:08:58Z | 59,609 | 23,262 | 15,914 | 98,785 | | ||||
| | [v2.4.8](https://github.com/laurent22/joplin/releases/tag/v2.4.8) (p) | 2021-09-22T19:01:46Z | 10,121 | 1,774 | 535   | 12,430 | | ||||
| | [v2.4.7](https://github.com/laurent22/joplin/releases/tag/v2.4.7) (p) | 2021-09-19T12:53:22Z | 4,259 | 259   | 210   | 4,728 | | ||||
| | [v2.4.6](https://github.com/laurent22/joplin/releases/tag/v2.4.6) (p) | 2021-09-09T18:57:17Z | 5,023 | 460   | 520   | 6,003 | | ||||
| | [v2.4.5](https://github.com/laurent22/joplin/releases/tag/v2.4.5) (p) | 2021-09-06T18:03:28Z | 4,264 | 275   | 225   | 4,764 | | ||||
| | [v2.4.4](https://github.com/laurent22/joplin/releases/tag/v2.4.4) (p) | 2021-08-30T16:02:51Z | 4,473 | 380   | 373   | 5,226 | | ||||
| | [v2.4.3](https://github.com/laurent22/joplin/releases/tag/v2.4.3) (p) | 2021-08-28T15:27:32Z | 3,963 | 207   | 181   | 4,351 | | ||||
| | [v2.4.2](https://github.com/laurent22/joplin/releases/tag/v2.4.2) (p) | 2021-08-27T17:13:21Z | 3,742 | 151   | 91    | 3,984 | | ||||
| | [v2.4.1](https://github.com/laurent22/joplin/releases/tag/v2.4.1) (p) | 2021-08-21T11:52:30Z | 4,579 | 372   | 337   | 5,288 | | ||||
| | [v2.3.5](https://github.com/laurent22/joplin/releases/tag/v2.3.5) | 2021-08-17T06:43:30Z | 85,691 | 31,439 | 33,145 | 150,275 | | ||||
| | [v2.3.3](https://github.com/laurent22/joplin/releases/tag/v2.3.3) | 2021-08-14T09:19:40Z | 18,869 | 6,886 | 4,062 | 29,817 | | ||||
| | [v2.2.7](https://github.com/laurent22/joplin/releases/tag/v2.2.7) | 2021-08-11T11:03:26Z | 18,724 | 7,524 | 2,607 | 28,855 | | ||||
| | [v2.2.6](https://github.com/laurent22/joplin/releases/tag/v2.2.6) (p) | 2021-08-09T19:29:20Z | 10,994 | 4,619 | 958   | 16,571 | | ||||
| | [v2.2.5](https://github.com/laurent22/joplin/releases/tag/v2.2.5) (p) | 2021-08-07T10:35:24Z | 4,310 | 278   | 208   | 4,796 | | ||||
| | [v2.2.4](https://github.com/laurent22/joplin/releases/tag/v2.2.4) (p) | 2021-08-05T16:42:48Z | 3,972 | 208   | 133   | 4,313 | | ||||
| | [v2.2.2](https://github.com/laurent22/joplin/releases/tag/v2.2.2) (p) | 2021-07-19T10:28:35Z | 5,891 | 737   | 648   | 7,276 | | ||||
| | [v2.1.9](https://github.com/laurent22/joplin/releases/tag/v2.1.9) | 2021-07-19T10:28:43Z | 50,342 | 18,962 | 16,822 | 86,126 | | ||||
| | [v2.2.1](https://github.com/laurent22/joplin/releases/tag/v2.2.1) (p) | 2021-07-09T17:38:25Z | 5,511 | 417   | 394   | 6,322 | | ||||
| | [v2.1.8](https://github.com/laurent22/joplin/releases/tag/v2.1.8) | 2021-07-03T08:25:16Z | 33,780 | 12,206 | 12,736 | 58,722 | | ||||
| | [v2.1.7](https://github.com/laurent22/joplin/releases/tag/v2.1.7) | 2021-06-26T19:48:55Z | 17,095 | 6,410 | 3,638 | 27,143 | | ||||
| | [v2.1.5](https://github.com/laurent22/joplin/releases/tag/v2.1.5) (p) | 2021-06-23T15:08:52Z | 4,409 | 254   | 202   | 4,865 | | ||||
| | [v2.1.3](https://github.com/laurent22/joplin/releases/tag/v2.1.3) (p) | 2021-06-19T16:32:51Z | 4,475 | 312   | 217   | 5,004 | | ||||
| | [v2.0.11](https://github.com/laurent22/joplin/releases/tag/v2.0.11) | 2021-06-16T17:55:49Z | 26,862 | 9,282 | 9,908 | 46,052 | | ||||
| | [v2.0.10](https://github.com/laurent22/joplin/releases/tag/v2.0.10) | 2021-06-16T07:58:29Z | 6,025 | 946   | 399   | 7,370 | | ||||
| | [v2.0.9](https://github.com/laurent22/joplin/releases/tag/v2.0.9) (p) | 2021-06-12T09:30:30Z | 4,484 | 307   | 900   | 5,691 | | ||||
| | [v2.0.8](https://github.com/laurent22/joplin/releases/tag/v2.0.8) (p) | 2021-06-10T16:15:08Z | 3,904 | 246   | 599   | 4,749 | | ||||
| | [v2.0.4](https://github.com/laurent22/joplin/releases/tag/v2.0.4) (p) | 2021-06-02T12:54:17Z | 1,642 | 404   | 393   | 2,439 | | ||||
| | [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 5,892 | 503   | 1,681 | 8,076 | | ||||
| | [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 948   | 288   | 1,039 | 2,275 | | ||||
| | [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 41,897 | 16,306 | 19,440 | 77,643 | | ||||
| | [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 3,740 | 152   | 473   | 4,365 | | ||||
| | [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 4,764 | 320   | 951   | 6,035 | | ||||
| | [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 5,043 | 451   | 1,300 | 6,794 | | ||||
| | [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 6,063 | 840   | 2,466 | 9,369 | | ||||
| | [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 121,096 | 43,007 | 64,457 | 228,560 | | ||||
| | [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 14,610 | 4,883 | 4,529 | 24,022 | | ||||
| | [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 5,997 | 503   | 1,681 | 8,181 | | ||||
| | [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 950   | 288   | 1,039 | 2,277 | | ||||
| | [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 42,030 | 16,310 | 19,440 | 77,780 | | ||||
| | [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 3,846 | 152   | 473   | 4,471 | | ||||
| | [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 4,870 | 320   | 951   | 6,141 | | ||||
| | [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 5,152 | 451   | 1,300 | 6,903 | | ||||
| | [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 6,168 | 840   | 2,466 | 9,474 | | ||||
| | [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 121,243 | 43,010 | 64,462 | 228,715 | | ||||
| | [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 14,623 | 4,883 | 4,529 | 24,035 | | ||||
| | [v1.7.9](https://github.com/laurent22/joplin/releases/tag/v1.7.9) (p) | 2021-01-28T09:50:21Z | 531   | 148   | 514   | 1,193 | | ||||
| | [v1.7.6](https://github.com/laurent22/joplin/releases/tag/v1.7.6) (p) | 2021-01-27T10:36:05Z | 346   | 108   | 304   | 758   | | ||||
| | [v1.7.5](https://github.com/laurent22/joplin/releases/tag/v1.7.5) (p) | 2021-01-26T09:53:05Z | 439   | 219   | 468   | 1,126 | | ||||
| | [v1.7.4](https://github.com/laurent22/joplin/releases/tag/v1.7.4) (p) | 2021-01-22T17:58:38Z | 729   | 219   | 641   | 1,589 | | ||||
| | [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 23,464 | 7,731 | 7,636 | 38,831 | | ||||
| | [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 23,625 | 7,731 | 7,636 | 38,992 | | ||||
| | [v1.7.3](https://github.com/laurent22/joplin/releases/tag/v1.7.3) (p) | 2021-01-20T11:23:50Z | 383   | 95    | 459   | 937   | | ||||
| | [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 14,999 | 4,669 | 4,572 | 24,240 | | ||||
| | [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,865 | 3,441 | 4,819 | 21,125 | | ||||
| | [v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 4,007 | 91    | 324   | 4,422 | | ||||
| | [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 15,128 | 4,669 | 4,572 | 24,369 | | ||||
| | [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,865 | 3,441 | 4,820 | 21,126 | | ||||
| | [v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 4,111 | 91    | 324   | 4,526 | | ||||
| | [v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 422   | 93    | 220   | 735   | | ||||
| | [v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 704   | 242   | 606   | 1,552 | | ||||
| | [v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 15,502 | 5,233 | 5,551 | 26,286 | | ||||
| | [v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 705   | 242   | 606   | 1,553 | | ||||
| | [v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 15,630 | 5,233 | 5,551 | 26,414 | | ||||
| | [v1.6.1](https://github.com/laurent22/joplin/releases/tag/v1.6.1) (p) | 2020-12-29T19:37:45Z | 202   | 53    | 184   | 439   | | ||||
| | [v1.5.13](https://github.com/laurent22/joplin/releases/tag/v1.5.13) | 2020-12-29T18:29:15Z | 786   | 242   | 228   | 1,256 | | ||||
| | [v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,567 | 1,796 | 943   | 5,306 | | ||||
| | [v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 14,417 | 4,662 | 4,309 | 23,388 | | ||||
| | [v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 14,417 | 4,662 | 4,311 | 23,390 | | ||||
| | [v1.5.10](https://github.com/laurent22/joplin/releases/tag/v1.5.10) (p) | 2020-12-26T12:35:36Z | 324   | 124   | 286   | 734   | | ||||
| | [v1.5.9](https://github.com/laurent22/joplin/releases/tag/v1.5.9) (p) | 2020-12-23T18:01:08Z | 358   | 386   | 427   | 1,171 | | ||||
| | [v1.5.8](https://github.com/laurent22/joplin/releases/tag/v1.5.8) (p) | 2020-12-20T09:45:19Z | 594   | 179   | 660   | 1,433 | | ||||
| | [v1.5.7](https://github.com/laurent22/joplin/releases/tag/v1.5.7) (p) | 2020-12-10T12:58:33Z | 920   | 269   | 1,010 | 2,199 | | ||||
| | [v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 732   | 182   | 652   | 1,566 | | ||||
| | [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 29,857 | 13,629 | 11,714 | 55,200 | | ||||
| | [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,757 | 3,910 | 3,162 | 18,829 | | ||||
| | [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 29,982 | 13,630 | 11,715 | 55,327 | | ||||
| | [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,757 | 3,910 | 3,163 | 18,830 | | ||||
| | [v1.4.16](https://github.com/laurent22/joplin/releases/tag/v1.4.16) | 2020-11-27T19:40:16Z | 1,648 | 861   | 621   | 3,130 | | ||||
| | [v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 1,037 | 513   | 296   | 1,846 | | ||||
| | [v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 3,219 | 1,376 | 1,328 | 5,923 | | ||||
| | [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 4,800 | 191   | 611   | 5,602 | | ||||
| | [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 694   | 232   | 703   | 1,629 | | ||||
| | [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 4,906 | 192   | 611   | 5,709 | | ||||
| | [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 695   | 232   | 703   | 1,630 | | ||||
| | [v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 873   | 177   | 420   | 1,470 | | ||||
| | [v1.4.7](https://github.com/laurent22/joplin/releases/tag/v1.4.7) (p) | 2020-11-07T18:23:29Z | 559   | 208   | 534   | 1,301 | | ||||
| | [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 35,449 | 11,388 | 10,548 | 57,385 | | ||||
| | [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 35,573 | 11,388 | 10,548 | 57,509 | | ||||
| | [v1.3.17](https://github.com/laurent22/joplin/releases/tag/v1.3.17) (p) | 2020-11-06T11:35:15Z | 86    | 64    | 43    | 193   | | ||||
| | [v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 753   | 128   | 70    | 951   | | ||||
| | [v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,795 | 1,344 | 871   | 5,010 | | ||||
| @@ -243,137 +244,137 @@ updated: 2025-06-01T02:15:29Z | ||||
| | [v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 156   | 81    | 52    | 289   | | ||||
| | [v1.3.2](https://github.com/laurent22/joplin/releases/tag/v1.3.2) (p) | 2020-10-11T20:39:49Z | 711   | 217   | 587   | 1,515 | | ||||
| | [v1.3.1](https://github.com/laurent22/joplin/releases/tag/v1.3.1) (p) | 2020-10-11T15:10:18Z | 121   | 87    | 63    | 271   | | ||||
| | [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 49,405 | 17,792 | 14,091 | 81,288 | | ||||
| | [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 49,533 | 17,792 | 14,093 | 81,418 | | ||||
| | [v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 860   | 288   | 821   | 1,969 | | ||||
| | [v1.2.3](https://github.com/laurent22/joplin/releases/tag/v1.2.3) (p) | 2020-09-29T15:13:02Z | 258   | 100   | 102   | 460   | | ||||
| | [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 1,155 | 242   | 658   | 2,055 | | ||||
| | [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 28,233 | 13,555 | 7,785 | 49,573 | | ||||
| | [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 1,155 | 243   | 658   | 2,056 | | ||||
| | [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 28,233 | 13,555 | 7,786 | 49,574 | | ||||
| | [v1.1.3](https://github.com/laurent22/joplin/releases/tag/v1.1.3) (p) | 2020-09-17T10:30:37Z | 626   | 189   | 484   | 1,299 | | ||||
| | [v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 420   | 152   | 271   | 843   | | ||||
| | [v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 597   | 232   | 371   | 1,200 | | ||||
| | [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 22,959 | 10,065 | 5,676 | 38,700 | | ||||
| | [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 22,966 | 10,065 | 5,676 | 38,707 | | ||||
| | [v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,908 | 6,463 | 3,046 | 22,417 | | ||||
| | [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 26,664 | 5,987 | 5,147 | 37,798 | | ||||
| | [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 26,671 | 5,987 | 5,149 | 37,807 | | ||||
| | [v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 992   | 268   | 425   | 1,685 | | ||||
| | [v1.0.237](https://github.com/laurent22/joplin/releases/tag/v1.0.237) (p) | 2020-08-29T15:38:04Z | 637   | 963   | 360   | 1,960 | | ||||
| | [v1.0.236](https://github.com/laurent22/joplin/releases/tag/v1.0.236) (p) | 2020-08-28T09:16:54Z | 360   | 150   | 126   | 636   | | ||||
| | [v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 2,065 | 528   | 944   | 3,537 | | ||||
| | [v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 698   | 164   | 123   | 985   | | ||||
| | [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 48,026 | 18,246 | 12,387 | 78,659 | | ||||
| | [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 48,154 | 18,246 | 12,388 | 78,788 | | ||||
| | [v1.0.232](https://github.com/laurent22/joplin/releases/tag/v1.0.232) (p) | 2020-07-28T22:34:40Z | 703   | 262   | 201   | 1,166 | | ||||
| | [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 41,979 | 15,324 | 9,674 | 66,977 | | ||||
| | [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 41,979 | 15,324 | 9,677 | 66,980 | | ||||
| | [v1.0.226](https://github.com/laurent22/joplin/releases/tag/v1.0.226) (p) | 2020-07-04T10:21:26Z | 4,978 | 2,293 | 710   | 7,981 | | ||||
| | [v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 25,297 | 11,049 | 6,031 | 42,377 | | ||||
| | [v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 231   | 152   | 101   | 484   | | ||||
| | [v1.0.221](https://github.com/laurent22/joplin/releases/tag/v1.0.221) (p) | 2020-06-20T01:44:20Z | 901   | 247   | 235   | 1,383 | | ||||
| | [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 32,825 | 9,970 | 6,446 | 49,241 | | ||||
| | [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 32,826 | 9,970 | 6,447 | 49,243 | | ||||
| | [v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,642 | 7,018 | 3,154 | 24,814 | | ||||
| | [v1.0.217](https://github.com/laurent22/joplin/releases/tag/v1.0.217) (p) | 2020-06-06T15:17:27Z | 280   | 138   | 85    | 503   | | ||||
| | [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 41,798 | 14,355 | 10,220 | 66,373 | | ||||
| | [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 41,930 | 14,357 | 10,221 | 66,508 | | ||||
| | [v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 7,107 | 3,513 | 789   | 11,409 | | ||||
| | [v1.0.212](https://github.com/laurent22/joplin/releases/tag/v1.0.212) (p) | 2020-05-21T07:48:39Z | 255   | 112   | 72    | 439   | | ||||
| | [v1.0.211](https://github.com/laurent22/joplin/releases/tag/v1.0.211) (p) | 2020-05-20T08:59:16Z | 354   | 174   | 113   | 641   | | ||||
| | [v1.0.209](https://github.com/laurent22/joplin/releases/tag/v1.0.209) (p) | 2020-05-17T18:32:51Z | 1,449 | 894   | 173   | 2,516 | | ||||
| | [v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,252 | 305   | 1,044 | 2,601 | | ||||
| | [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 54,411 | 20,102 | 18,218 | 92,731 | | ||||
| | [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 54,414 | 20,102 | 18,220 | 92,736 | | ||||
| | [v1.0.200](https://github.com/laurent22/joplin/releases/tag/v1.0.200) | 2020-04-12T12:17:46Z | 9,637 | 4,935 | 1,931 | 16,503 | | ||||
| | [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,883 | 5,932 | 3,827 | 29,642 | | ||||
| | [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 23,514 | 9,870 | 6,629 | 40,013 | | ||||
| | [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 19,178 | 7,997 | 4,535 | 31,710 | | ||||
| | [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,899 | 5,932 | 3,827 | 29,658 | | ||||
| | [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 23,517 | 9,870 | 6,641 | 40,028 | | ||||
| | [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 19,178 | 7,998 | 4,535 | 31,711 | | ||||
| | [v1.0.194](https://github.com/laurent22/joplin/releases/tag/v1.0.194) (p) | 2020-03-14T00:00:32Z | 1,339 | 1,431 | 543   | 3,313 | | ||||
| | [v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,845 | 10,968 | 7,445 | 47,258 | | ||||
| | [v1.0.192](https://github.com/laurent22/joplin/releases/tag/v1.0.192) (p) | 2020-03-06T23:27:52Z | 563   | 164   | 113   | 840   | | ||||
| | [v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 441   | 133   | 110   | 684   | | ||||
| | [v1.0.189](https://github.com/laurent22/joplin/releases/tag/v1.0.189) (p) | 2020-03-04T17:27:15Z | 432   | 136   | 122   | 690   | | ||||
| | [v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 983   | 277   | 298   | 1,558 | | ||||
| | [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,769 | 29,127 | 22,597 | 123,493 | | ||||
| | [v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,678 | 6,006 | 2,620 | 26,304 | | ||||
| | [v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 2,013 | 478   | 750   | 3,241 | | ||||
| | [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,780 | 29,128 | 22,597 | 123,505 | | ||||
| | [v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,678 | 6,006 | 2,621 | 26,305 | | ||||
| | [v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 2,013 | 478   | 752   | 3,243 | | ||||
| | [v1.0.176](https://github.com/laurent22/joplin/releases/tag/v1.0.176) (p) | 2019-12-14T10:36:44Z | 3,176 | 2,579 | 496   | 6,251 | | ||||
| | [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 73,798 | 17,022 | 16,625 | 107,445 | | ||||
| | [v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,755 | 11,804 | 8,254 | 50,813 | | ||||
| | [v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,154 | 2,123 | 773   | 8,050 | | ||||
| | [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,879 | 8,825 | 7,716 | 44,420 | | ||||
| | [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,291 | 5,973 | 3,785 | 27,049 | | ||||
| | [v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,394 | 2,316 | 745   | 8,455 | | ||||
| | [v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 17,013 | 5,747 | 3,741 | 26,501 | | ||||
| | [v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 2,006 | 601   | 261   | 2,868 | | ||||
| | [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 19,121 | 7,022 | 5,491 | 31,634 | | ||||
| | [v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,452 | 6,391 | 4,161 | 30,004 | | ||||
| | [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,870 | 7,796 | 8,134 | 46,800 | | ||||
| | [v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,272 | 2,220 | 1,309 | 8,801 | | ||||
| | [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 73,802 | 17,023 | 16,625 | 107,450 | | ||||
| | [v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,765 | 11,806 | 8,255 | 50,826 | | ||||
| | [v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,154 | 2,123 | 774   | 8,051 | | ||||
| | [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,888 | 8,827 | 7,716 | 44,431 | | ||||
| | [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,294 | 5,973 | 3,785 | 27,052 | | ||||
| | [v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,394 | 2,316 | 747   | 8,457 | | ||||
| | [v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 17,015 | 5,747 | 3,742 | 26,504 | | ||||
| | [v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 2,006 | 603   | 261   | 2,870 | | ||||
| | [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 19,123 | 7,022 | 5,491 | 31,636 | | ||||
| | [v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,453 | 6,391 | 4,161 | 30,005 | | ||||
| | [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,871 | 7,796 | 8,134 | 46,801 | | ||||
| | [v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,273 | 2,220 | 1,315 | 8,808 | | ||||
| | [v1.0.158](https://github.com/laurent22/joplin/releases/tag/v1.0.158) | 2019-05-27T19:01:18Z | 9,891 | 3,590 | 1,962 | 15,443 | | ||||
| | [v1.0.157](https://github.com/laurent22/joplin/releases/tag/v1.0.157) | 2019-05-26T17:55:53Z | 2,248 | 881   | 314   | 3,443 | | ||||
| | [v1.0.153](https://github.com/laurent22/joplin/releases/tag/v1.0.153) (p) | 2019-05-15T06:27:29Z | 916   | 143   | 131   | 1,190 | | ||||
| | [v1.0.152](https://github.com/laurent22/joplin/releases/tag/v1.0.152) | 2019-05-13T09:08:07Z | 13,948 | 4,475 | 4,091 | 22,514 | | ||||
| | [v1.0.151](https://github.com/laurent22/joplin/releases/tag/v1.0.151) | 2019-05-12T15:14:32Z | 2,002 | 575   | 984   | 3,561 | | ||||
| | [v1.0.152](https://github.com/laurent22/joplin/releases/tag/v1.0.152) | 2019-05-13T09:08:07Z | 13,948 | 4,477 | 4,092 | 22,517 | | ||||
| | [v1.0.151](https://github.com/laurent22/joplin/releases/tag/v1.0.151) | 2019-05-12T15:14:32Z | 2,003 | 575   | 985   | 3,563 | | ||||
| | [v1.0.150](https://github.com/laurent22/joplin/releases/tag/v1.0.150) | 2019-05-12T11:27:48Z | 496   | 175   | 96    | 767   | | ||||
| | [v1.0.148](https://github.com/laurent22/joplin/releases/tag/v1.0.148) (p) | 2019-05-08T19:12:24Z | 186   | 93    | 120   | 399   | | ||||
| | [v1.0.145](https://github.com/laurent22/joplin/releases/tag/v1.0.145) | 2019-05-03T09:16:53Z | 7,083 | 2,901 | 1,465 | 11,449 | | ||||
| | [v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,994 | 3,593 | 2,809 | 18,396 | | ||||
| | [v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,863 | 4,607 | 4,757 | 24,227 | | ||||
| | [v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,994 | 3,593 | 2,811 | 18,398 | | ||||
| | [v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,863 | 4,607 | 4,758 | 24,228 | | ||||
| | [v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,729 | 4,218 | 3,407 | 21,354 | | ||||
| | [v1.0.139](https://github.com/laurent22/joplin/releases/tag/v1.0.139) (p) | 2019-03-09T10:06:48Z | 186   | 104   | 69    | 359   | | ||||
| | [v1.0.138](https://github.com/laurent22/joplin/releases/tag/v1.0.138) (p) | 2019-03-03T17:23:00Z | 230   | 128   | 107   | 465   | | ||||
| | [v1.0.137](https://github.com/laurent22/joplin/releases/tag/v1.0.137) (p) | 2019-03-03T01:12:51Z | 650   | 95    | 107   | 852   | | ||||
| | [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,704 | 3,998 | 4,108 | 20,810 | | ||||
| | [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,704 | 3,998 | 4,109 | 20,811 | | ||||
| | [v1.0.134](https://github.com/laurent22/joplin/releases/tag/v1.0.134) | 2019-02-27T10:21:44Z | 1,512 | 610   | 244   | 2,366 | | ||||
| | [v1.0.132](https://github.com/laurent22/joplin/releases/tag/v1.0.132) | 2019-02-26T23:02:05Z | 1,150 | 491   | 119   | 1,760 | | ||||
| | [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 10,028 | 3,214 | 2,960 | 16,202 | | ||||
| | [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 10,029 | 3,214 | 2,961 | 16,204 | | ||||
| | [v1.0.126](https://github.com/laurent22/joplin/releases/tag/v1.0.126) (p) | 2019-02-09T19:46:16Z | 1,001 | 115   | 138   | 1,254 | | ||||
| | [v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,416 | 3,597 | 1,726 | 15,739 | | ||||
| | [v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,706 | 5,253 | 6,548 | 27,507 | | ||||
| | [v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,421 | 3,597 | 1,726 | 15,744 | | ||||
| | [v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,706 | 5,253 | 6,549 | 27,508 | | ||||
| | [v1.0.119](https://github.com/laurent22/joplin/releases/tag/v1.0.119) | 2018-12-18T12:40:22Z | 9,011 | 3,302 | 2,041 | 14,354 | | ||||
| | [v1.0.118](https://github.com/laurent22/joplin/releases/tag/v1.0.118) | 2019-01-11T08:34:13Z | 763   | 288   | 115   | 1,166 | | ||||
| | [v1.0.117](https://github.com/laurent22/joplin/releases/tag/v1.0.117) | 2018-11-24T12:05:24Z | 16,348 | 4,940 | 6,409 | 27,697 | | ||||
| | [v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 4,069 | 1,167 | 740   | 5,976 | | ||||
| | [v1.0.117](https://github.com/laurent22/joplin/releases/tag/v1.0.117) | 2018-11-24T12:05:24Z | 16,348 | 4,940 | 6,412 | 27,700 | | ||||
| | [v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 4,069 | 1,169 | 742   | 5,980 | | ||||
| | [v1.0.115](https://github.com/laurent22/joplin/releases/tag/v1.0.115) | 2018-11-16T16:52:02Z | 3,723 | 1,341 | 827   | 5,891 | | ||||
| | [v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,471 | 3,540 | 3,855 | 18,866 | | ||||
| | [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,544 | 3,555 | 3,707 | 19,806 | | ||||
| | [v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,471 | 3,540 | 3,856 | 18,867 | | ||||
| | [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,558 | 3,563 | 3,708 | 19,829 | | ||||
| | [v1.0.110](https://github.com/laurent22/joplin/releases/tag/v1.0.110) | 2018-09-29T12:29:21Z | 1,010 | 449   | 142   | 1,601 | | ||||
| | [v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,146 | 743   | 356   | 3,245 | | ||||
| | [v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,146 | 743   | 357   | 3,246 | | ||||
| | [v1.0.108](https://github.com/laurent22/joplin/releases/tag/v1.0.108) (p) | 2018-09-29T18:49:29Z | 66    | 60    | 38    | 164   | | ||||
| | [v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,209 | 2,175 | 1,737 | 11,121 | | ||||
| | [v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,209 | 2,175 | 1,738 | 11,122 | | ||||
| | [v1.0.106](https://github.com/laurent22/joplin/releases/tag/v1.0.106) | 2018-09-08T15:23:40Z | 4,601 | 1,495 | 341   | 6,437 | | ||||
| | [v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,702 | 1,634 | 1,485 | 7,821 | | ||||
| | [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,208 | 4,743 | 7,411 | 27,362 | | ||||
| | [v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,128 | 926   | 703   | 3,757 | | ||||
| | [v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,375 | 648   | 434   | 2,457 | | ||||
| | [v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 960   | 477   | 272   | 1,709 | | ||||
| | [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,208 | 4,743 | 7,414 | 27,365 | | ||||
| | [v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,130 | 926   | 704   | 3,760 | | ||||
| | [v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,375 | 648   | 436   | 2,459 | | ||||
| | [v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 961   | 477   | 274   | 1,712 | | ||||
| | [v1.0.99](https://github.com/laurent22/joplin/releases/tag/v1.0.99) | 2018-06-10T13:18:23Z | 1,329 | 642   | 409   | 2,380 | | ||||
| | [v1.0.97](https://github.com/laurent22/joplin/releases/tag/v1.0.97) | 2018-06-09T19:23:34Z | 357   | 193   | 87    | 637   | | ||||
| | [v1.0.96](https://github.com/laurent22/joplin/releases/tag/v1.0.96) | 2018-05-26T16:36:39Z | 2,800 | 1,267 | 1,749 | 5,816 | | ||||
| | [v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 466   | 258   | 163   | 887   | | ||||
| | [v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,214 | 629   | 444   | 2,287 | | ||||
| | [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,879 | 1,358 | 804   | 4,041 | | ||||
| | [v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 468   | 258   | 163   | 889   | | ||||
| | [v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,216 | 629   | 446   | 2,291 | | ||||
| | [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,880 | 1,359 | 804   | 4,043 | | ||||
| | [v1.0.91](https://github.com/laurent22/joplin/releases/tag/v1.0.91) | 2018-05-10T14:48:04Z | 872   | 594   | 364   | 1,830 | | ||||
| | [v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 546   | 286   | 157   | 989   | | ||||
| | [v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,696 | 992   | 677   | 3,365 | | ||||
| | [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 5,630 | 2,577 | 2,703 | 10,910 | | ||||
| | [v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,698 | 992   | 677   | 3,367 | | ||||
| | [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 5,632 | 2,577 | 2,703 | 10,912 | | ||||
| | [v1.0.82](https://github.com/laurent22/joplin/releases/tag/v1.0.82) | 2018-03-31T19:16:31Z | 751   | 450   | 167   | 1,368 | | ||||
| | [v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,039 | 637   | 826   | 2,502 | | ||||
| | [v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,039 | 637   | 827   | 2,503 | | ||||
| | [v1.0.79](https://github.com/laurent22/joplin/releases/tag/v1.0.79) | 2018-03-23T18:00:11Z | 971   | 580   | 428   | 1,979 | | ||||
| | [v1.0.78](https://github.com/laurent22/joplin/releases/tag/v1.0.78) | 2018-03-17T15:27:18Z | 1,354 | 943   | 915   | 3,212 | | ||||
| | [v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 202   | 144   | 88    | 434   | | ||||
| | [v1.0.72](https://github.com/laurent22/joplin/releases/tag/v1.0.72) | 2018-03-14T09:44:35Z | 451   | 296   | 98    | 845   | | ||||
| | [v1.0.70](https://github.com/laurent22/joplin/releases/tag/v1.0.70) | 2018-02-28T20:04:30Z | 2,004 | 1,093 | 1,296 | 4,393 | | ||||
| | [v1.0.70](https://github.com/laurent22/joplin/releases/tag/v1.0.70) | 2018-02-28T20:04:30Z | 2,004 | 1,093 | 1,297 | 4,394 | | ||||
| | [v1.0.67](https://github.com/laurent22/joplin/releases/tag/v1.0.67) | 2018-02-19T22:51:08Z | 1,947 | 645   | 0     | 2,592 | | ||||
| | [v1.0.66](https://github.com/laurent22/joplin/releases/tag/v1.0.66) | 2018-02-18T23:09:09Z | 450   | 174   | 109   | 733   | | ||||
| | [v1.0.65](https://github.com/laurent22/joplin/releases/tag/v1.0.65) | 2018-02-17T20:02:25Z | 349   | 171   | 156   | 676   | | ||||
| | [v1.0.64](https://github.com/laurent22/joplin/releases/tag/v1.0.64) | 2018-02-16T00:58:20Z | 1,196 | 583   | 1,148 | 2,927 | | ||||
| | [v1.0.63](https://github.com/laurent22/joplin/releases/tag/v1.0.63) | 2018-02-14T19:40:36Z | 413   | 200   | 115   | 728   | | ||||
| | [v1.0.63](https://github.com/laurent22/joplin/releases/tag/v1.0.63) | 2018-02-14T19:40:36Z | 413   | 200   | 117   | 730   | | ||||
| | [v1.0.62](https://github.com/laurent22/joplin/releases/tag/v1.0.62) | 2018-02-12T20:19:58Z | 714   | 345   | 400   | 1,459 | | ||||
| | [v0.10.61](https://github.com/laurent22/joplin/releases/tag/v0.10.61) | 2018-02-08T18:27:39Z | 1,124 | 678   | 987   | 2,789 | | ||||
| | [v0.10.60](https://github.com/laurent22/joplin/releases/tag/v0.10.60) | 2018-02-06T13:09:56Z | 772   | 565   | 577   | 1,914 | | ||||
| | [v0.10.54](https://github.com/laurent22/joplin/releases/tag/v0.10.54) | 2018-01-31T20:21:30Z | 1,959 | 1,504 | 348   | 3,811 | | ||||
| | [v0.10.52](https://github.com/laurent22/joplin/releases/tag/v0.10.52) | 2018-01-31T19:25:18Z | 164   | 677   | 41    | 882   | | ||||
| | [v0.10.51](https://github.com/laurent22/joplin/releases/tag/v0.10.51) | 2018-01-28T18:47:02Z | 1,430 | 1,643 | 352   | 3,425 | | ||||
| | [v0.10.54](https://github.com/laurent22/joplin/releases/tag/v0.10.54) | 2018-01-31T20:21:30Z | 1,961 | 1,504 | 348   | 3,813 | | ||||
| | [v0.10.52](https://github.com/laurent22/joplin/releases/tag/v0.10.52) | 2018-01-31T19:25:18Z | 164   | 677   | 42    | 883   | | ||||
| | [v0.10.51](https://github.com/laurent22/joplin/releases/tag/v0.10.51) | 2018-01-28T18:47:02Z | 1,431 | 1,643 | 352   | 3,426 | | ||||
| | [v0.10.48](https://github.com/laurent22/joplin/releases/tag/v0.10.48) | 2018-01-23T11:19:51Z | 2,112 | 1,795 | 56    | 3,963 | | ||||
| | [v0.10.47](https://github.com/laurent22/joplin/releases/tag/v0.10.47) | 2018-01-16T17:27:17Z | 1,334 | 1,317 | 91    | 2,742 | | ||||
| | [v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,486 | 2,401 | 1,240 | 7,127 | | ||||
| | [v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,486 | 2,401 | 1,241 | 7,128 | | ||||
| | [v0.10.41](https://github.com/laurent22/joplin/releases/tag/v0.10.41) | 2018-01-05T20:38:12Z | 1,245 | 1,598 | 266   | 3,109 | | ||||
| | [v0.10.40](https://github.com/laurent22/joplin/releases/tag/v0.10.40) | 2018-01-02T23:16:57Z | 1,638 | 1,832 | 362   | 3,832 | | ||||
| | [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,995 | 4,445 | 3,328 | 13,768 | | ||||
| | [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,995 | 4,445 | 3,329 | 13,769 | | ||||
| | [v0.10.38](https://github.com/laurent22/joplin/releases/tag/v0.10.38) | 2017-12-08T10:12:06Z | 1,089 | 1,274 | 330   | 2,693 | | ||||
| | [v0.10.37](https://github.com/laurent22/joplin/releases/tag/v0.10.37) | 2017-12-07T19:38:05Z | 291   | 892   | 109   | 1,292 | | ||||
| | [v0.10.36](https://github.com/laurent22/joplin/releases/tag/v0.10.36) | 2017-12-05T09:34:40Z | 1,052 | 1,405 | 465   | 2,922 | | ||||
| @@ -382,11 +383,11 @@ updated: 2025-06-01T02:15:29Z | ||||
| | [v0.10.33](https://github.com/laurent22/joplin/releases/tag/v0.10.33) | 2017-12-02T13:20:39Z | 95    | 711   | 51    | 857   | | ||||
| | [v0.10.31](https://github.com/laurent22/joplin/releases/tag/v0.10.31) | 2017-12-01T09:56:44Z | 918   | 1,499 | 437   | 2,854 | | ||||
| | [v0.10.30](https://github.com/laurent22/joplin/releases/tag/v0.10.30) | 2017-11-30T20:28:16Z | 842   | 1,428 | 452   | 2,722 | | ||||
| | [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,492 | 1,761 | 906   | 4,159 | | ||||
| | [v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 307   | 756   | 291   | 1,354 | | ||||
| | [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 259   | 752   | 6,780 | 7,791 | | ||||
| | [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,492 | 1,761 | 908   | 4,161 | | ||||
| | [v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 307   | 757   | 291   | 1,355 | | ||||
| | [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 259   | 752   | 6,784 | 7,795 | | ||||
| | [v0.10.23](https://github.com/laurent22/joplin/releases/tag/v0.10.23) | 2017-11-21T19:38:41Z | 247   | 714   | 67    | 1,028 | | ||||
| | [v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 192   | 700   | 50    | 942   | | ||||
| | [v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 192   | 701   | 50    | 943   | | ||||
| | [v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 170   | 693   | 44    | 907   | | ||||
| | [v0.10.20](https://github.com/laurent22/joplin/releases/tag/v0.10.20) | 2017-11-17T17:18:25Z | 161   | 706   | 56    | 923   | | ||||
| | [v0.10.20](https://github.com/laurent22/joplin/releases/tag/v0.10.20) | 2017-11-17T17:18:25Z | 163   | 706   | 57    | 926   | | ||||
| | [v0.10.19](https://github.com/laurent22/joplin/releases/tag/v0.10.19) | 2017-11-20T18:59:48Z | 183   | 707   | 55    | 945   | | ||||
							
								
								
									
										95
									
								
								readme/apps/joplin_server_business.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,95 @@ | ||||
| # Joplin Server Business | ||||
|  | ||||
| <div style="overflow: auto;"> | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/joplin_server_business/main.png" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/> | ||||
|  | ||||
| Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business. | ||||
|  | ||||
| Your teams can collaborate on notebooks and share information. They can also publish notes to the internet or within your own intranet. All that secured by Joplin end-to-end encryption. | ||||
|  | ||||
| Interested? [Contact us for a quote](mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry) | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## Smart teamwork with Joplin Server | ||||
|  | ||||
| ### Self-host to keep your data within your organisation | ||||
|  | ||||
| <div style="overflow: auto;"> | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/joplin_server_business/self_host.jpg" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/> | ||||
|  | ||||
| The data is hosted on your own server, giving you full control over it and ensuring it stays within your organisation. | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ### Share and collaborate on a notebook | ||||
|  | ||||
| <div style="overflow: auto;"> | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/joplin_server_business/share.jpg" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/> | ||||
|  | ||||
| Our service allows you to share notes and documents across unlimited devices. Create and modify teams to manage projects and planning. | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ### Publish notes to the internet | ||||
|  | ||||
| <div style="overflow: auto;"> | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/joplin_server_business/publish.jpg" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/> | ||||
|  | ||||
| You can publish a note so that it can be viewed in a browser by your colleagues and customers. The note can be available publicly on the internet or remain within your intranet. | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ### Manage multiples users and teams | ||||
|  | ||||
| <div style="overflow: auto;"> | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/joplin_server_business/teams.jpg" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/> | ||||
|  | ||||
| Using Joplin Server Business you can create and manage teams of users. Each team can collaborate on notebooks and notes and share information. | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## By choosing Joplin Server Business your organisation benefits also from other features including: | ||||
|  | ||||
| ### End-to-end encryption | ||||
|  | ||||
| Activate encryption to protect your data and secure communications across teams. | ||||
|  | ||||
| ### Web clipper | ||||
|  | ||||
| Capture web pages and screenshots and save them as notes in Joplin. | ||||
|  | ||||
| ### Open source code | ||||
|  | ||||
| Our desktop and mobile applications, as well as the end-to-end technology, are fully open source, ensuring transparency and increased security. | ||||
|  | ||||
| ### Synchronization across devices | ||||
|  | ||||
| Securely synchronise your data across multiple devices - including iOS, Android, Windows, macOS and Linux. | ||||
|  | ||||
| ### Customise it | ||||
|  | ||||
| Customise the app with plugins, custom themes and multiple text editors (Rich Text or Markdown). Or create your own company-specific workflow by developing scripts and plugins using the Extension API. | ||||
|  | ||||
| ### Open source code | ||||
|  | ||||
| Our desktop and mobile applications, as well as the end-to-end technology, are fully open source, ensuring transparency and increased security. | ||||
|  | ||||
| ### Multimedia notes (PDF, images, etc.) | ||||
|  | ||||
| Keep all your resources in one place. Save and share images, PDFs, videos, audio files and math expressions. | ||||
|  | ||||
| ## Did you know that there are over 150 plugins available for Joplin products ? | ||||
|  | ||||
| [Go to the plugin website](https://joplinapp.org/plugins/) | ||||
|  | ||||
| ## Ready to give it a try ? | ||||
|  | ||||
| To find out more about Joplin Server Business and how it can be integrated to your organisation, feel free to contact us. Our experts can prepare a demo for you. We can provide a quote to accommodate your company’s needs. | ||||
|  | ||||
| [Contact us for a quote!](mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry) | ||||
| @@ -27,7 +27,7 @@ The install and update script supports the [following flags](https://github.com/ | ||||
|  | ||||
| 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' style="max-height: 40px;" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeAndroid.png'/></a> | or download the [APK file](https://objects.joplinusercontent.com/v3.3.9/joplin-v3.3.9.apk?source=JoplinWebsite&type=New) | ||||
| 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' style="max-height: 40px;" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeAndroid.png'/></a> | or download the [APK file](https://objects.joplinusercontent.com/v3.3.11/joplin-v3.3.11.apk?source=JoplinWebsite&type=New) | ||||
| iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' style="max-height: 40px;" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeIOS.png'/></a> | - | ||||
|  | ||||
| ## Terminal application | ||||
|   | ||||
							
								
								
									
										76
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						| @@ -9468,7 +9468,7 @@ __metadata: | ||||
|     jest: "npm:29.7.0" | ||||
|     jest-environment-jsdom: "npm:29.7.0" | ||||
|     json-stringify-safe: "npm:5.0.1" | ||||
|     katex: "npm:0.16.21" | ||||
|     katex: "npm:0.16.22" | ||||
|     markdown-it: "npm:13.0.2" | ||||
|     markdown-it-abbr: "npm:1.0.4" | ||||
|     markdown-it-anchor: "npm:5.3.0" | ||||
| @@ -9529,7 +9529,7 @@ __metadata: | ||||
|     jquery: "npm:3.7.1" | ||||
|     jsdom: "npm:25.0.1" | ||||
|     knex: "npm:3.1.0" | ||||
|     koa: "npm:2.16.0" | ||||
|     koa: "npm:2.16.1" | ||||
|     ldapts: "npm:7.3.3" | ||||
|     markdown-it: "npm:13.0.2" | ||||
|     mustache: "npm:4.2.0" | ||||
| @@ -9538,7 +9538,7 @@ __metadata: | ||||
|     node-os-utils: "npm:1.3.7" | ||||
|     nodemailer: "npm:6.10.0" | ||||
|     nodemon: "npm:3.1.9" | ||||
|     pg: "npm:8.13.3" | ||||
|     pg: "npm:8.14.1" | ||||
|     pm2: "npm:5.4.3" | ||||
|     pretty-bytes: "npm:5.6.0" | ||||
|     prettycron: "npm:0.10.0" | ||||
| @@ -9625,7 +9625,7 @@ __metadata: | ||||
|     jest: "npm:29.7.0" | ||||
|     jest-expect-message: "npm:1.1.3" | ||||
|     knex: "npm:3.1.0" | ||||
|     koa: "npm:2.16.0" | ||||
|     koa: "npm:2.16.1" | ||||
|     koa-body: "npm:6.0.1" | ||||
|     pg-boss: "npm:10.1.6" | ||||
|     sqlite3: "npm:5.1.6" | ||||
| @@ -9651,7 +9651,7 @@ __metadata: | ||||
|   dependencies: | ||||
|     "@adobe/css-tools": "npm:4.4.2" | ||||
|     "@rollup/plugin-commonjs": "npm:28.0.3" | ||||
|     "@rollup/plugin-node-resolve": "npm:15.3.1" | ||||
|     "@rollup/plugin-node-resolve": "npm:16.0.1" | ||||
|     "@rollup/plugin-replace": "npm:6.0.2" | ||||
|     browserify: "npm:14.5.0" | ||||
|     html-entities: "npm:1.4.0" | ||||
| @@ -12428,9 +12428,9 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@rollup/plugin-node-resolve@npm:15.3.1": | ||||
|   version: 15.3.1 | ||||
|   resolution: "@rollup/plugin-node-resolve@npm:15.3.1" | ||||
| "@rollup/plugin-node-resolve@npm:16.0.1": | ||||
|   version: 16.0.1 | ||||
|   resolution: "@rollup/plugin-node-resolve@npm:16.0.1" | ||||
|   dependencies: | ||||
|     "@rollup/pluginutils": "npm:^5.0.1" | ||||
|     "@types/resolve": "npm:1.20.2" | ||||
| @@ -12442,7 +12442,7 @@ __metadata: | ||||
|   peerDependenciesMeta: | ||||
|     rollup: | ||||
|       optional: true | ||||
|   checksum: 10/874494c0daca8fb0d633a237dd9df0d30609b374326e57508710f2b6d7ddaa93d203d8daa0257960b2b6723f56dfec1177573126f31ff9604700303b6f5fdbe3 | ||||
|   checksum: 10/88fee8c003a5730cca2c06edd200ec6a46c7ab28bed3a99aea6d3070f34f980f575fcbea906946579e41b0be6fd7a2fbc24cdf0ca24f172a555f130726915d8b | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -33105,14 +33105,14 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "katex@npm:0.16.21": | ||||
|   version: 0.16.21 | ||||
|   resolution: "katex@npm:0.16.21" | ||||
| "katex@npm:0.16.22": | ||||
|   version: 0.16.22 | ||||
|   resolution: "katex@npm:0.16.22" | ||||
|   dependencies: | ||||
|     commander: "npm:^8.3.0" | ||||
|   bin: | ||||
|     katex: cli.js | ||||
|   checksum: 10/db1094c528972ffb881c64969e87cbca465d21f918f4dad8bfe583f68e1bd601438eda3d79e8d74bc7ccc14e7b76616a9053bb21945749a30a73bc68f20e721b | ||||
|   checksum: 10/fdb8667d9aa971154502b120ba340766754d202e3d3e322aca0a96de27032ad2dbb8a7295d798d310cd7ce4ddd21ed1f3318895541b61c9b4fdf611166589e02 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -33339,9 +33339,9 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "koa@npm:2.16.0": | ||||
|   version: 2.16.0 | ||||
|   resolution: "koa@npm:2.16.0" | ||||
| "koa@npm:2.16.1": | ||||
|   version: 2.16.1 | ||||
|   resolution: "koa@npm:2.16.1" | ||||
|   dependencies: | ||||
|     accepts: "npm:^1.3.5" | ||||
|     cache-content-type: "npm:^1.0.0" | ||||
| @@ -33366,7 +33366,7 @@ __metadata: | ||||
|     statuses: "npm:^1.5.0" | ||||
|     type-is: "npm:^1.6.16" | ||||
|     vary: "npm:^1.1.2" | ||||
|   checksum: 10/88284e5da49cd54a2db663c818f5370d00f32b6aefbe5ecfc75bdaf7937d3b08cfbb884d07564b8e2b856dfe74d930997a6bdca2e2090dc2bfae0fa8af56a214 | ||||
|   checksum: 10/f33b95227e48bffd3a682996e6cf72c4ae2992671529c6c914b76d28172219c9cbd8201b16cc028dc25fafc8f1dc9391a6e7e045740a10ee7d89a5631031a974 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -33828,9 +33828,9 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "lint-staged@npm:15.4.3": | ||||
|   version: 15.4.3 | ||||
|   resolution: "lint-staged@npm:15.4.3" | ||||
| "lint-staged@npm:15.5.0": | ||||
|   version: 15.5.0 | ||||
|   resolution: "lint-staged@npm:15.5.0" | ||||
|   dependencies: | ||||
|     chalk: "npm:^5.4.1" | ||||
|     commander: "npm:^13.1.0" | ||||
| @@ -33844,7 +33844,7 @@ __metadata: | ||||
|     yaml: "npm:^2.7.0" | ||||
|   bin: | ||||
|     lint-staged: bin/lint-staged.js | ||||
|   checksum: 10/14a6a9cb9b5e8027b1347cb24e114839d618d343d5c724c26def7d45ca9b9a9b813b585531c68f5a3d13332407c2dba198987a73f0350df483d99a876ba69c60 | ||||
|   checksum: 10/5873584649c5f840b990036c20abd4b58d6b1313dad5505627b4d0cc077f0ec8ac0d6cf4cf4d959e66e0ab085db384bb12dce9490ff29217bf4ed96d0442ed51 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -39269,7 +39269,7 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "pg-pool@npm:^3.10.0, pg-pool@npm:^3.7.1": | ||||
| "pg-pool@npm:^3.10.0": | ||||
|   version: 3.10.0 | ||||
|   resolution: "pg-pool@npm:3.10.0" | ||||
|   peerDependencies: | ||||
| @@ -39278,13 +39278,29 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "pg-protocol@npm:^1.10.0, pg-protocol@npm:^1.7.1": | ||||
| "pg-pool@npm:^3.8.0": | ||||
|   version: 3.10.1 | ||||
|   resolution: "pg-pool@npm:3.10.1" | ||||
|   peerDependencies: | ||||
|     pg: ">=8.0" | ||||
|   checksum: 10/b389a714be59ebe53ec412cbff513191cc0b7a203faa5d26416b6a038cafdfe30fbf1a5936b77bb76109c49bd7c4a116870a5a46a45796b1b34c96f016d7fbe2 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "pg-protocol@npm:^1.10.0": | ||||
|   version: 1.10.0 | ||||
|   resolution: "pg-protocol@npm:1.10.0" | ||||
|   checksum: 10/975184d9f67dd2325afc8b5e79008c39bbdf6baf43db1158a90a9c624c86d0ca51cff68031759e196739d2e04b90a6a4749b42206ab7b9aca03a25243a7c2094 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "pg-protocol@npm:^1.8.0": | ||||
|   version: 1.10.3 | ||||
|   resolution: "pg-protocol@npm:1.10.3" | ||||
|   checksum: 10/31da85319084c03f403efee7accce9786964df82a7feb60e6bd77b71f1e622c74a2a644a2bc434389d0ab92e5abdeedea69ebdb53b1897d9f01d2a1f51a8a2fe | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "pg-types@npm:2.2.0, pg-types@npm:^2.1.0": | ||||
|   version: 2.2.0 | ||||
|   resolution: "pg-types@npm:2.2.0" | ||||
| @@ -39298,14 +39314,14 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "pg@npm:8.13.3": | ||||
|   version: 8.13.3 | ||||
|   resolution: "pg@npm:8.13.3" | ||||
| "pg@npm:8.14.1": | ||||
|   version: 8.14.1 | ||||
|   resolution: "pg@npm:8.14.1" | ||||
|   dependencies: | ||||
|     pg-cloudflare: "npm:^1.1.1" | ||||
|     pg-connection-string: "npm:^2.7.0" | ||||
|     pg-pool: "npm:^3.7.1" | ||||
|     pg-protocol: "npm:^1.7.1" | ||||
|     pg-pool: "npm:^3.8.0" | ||||
|     pg-protocol: "npm:^1.8.0" | ||||
|     pg-types: "npm:^2.1.0" | ||||
|     pgpass: "npm:1.x" | ||||
|   peerDependencies: | ||||
| @@ -39316,7 +39332,7 @@ __metadata: | ||||
|   peerDependenciesMeta: | ||||
|     pg-native: | ||||
|       optional: true | ||||
|   checksum: 10/be1be61fa46f7ccc3441794e390c41fc548f1bbee9744e3e7fae00b3d91e3974c4c51e25c1013075ec3a289d9290cd01ee926357e68ba20fcbb15308dbdef87c | ||||
|   checksum: 10/45f2d5719fd74a6a4784c5115c0ff482af92d1e5b101bf423160b6a983e37cc2fad4a7eea2a06f27e6f8bdb8abce23486d2d522c8c52c90f68a2bc897f0553c4 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -43725,7 +43741,7 @@ __metadata: | ||||
|     http-server: "npm:14.1.1" | ||||
|     husky: "npm:9.1.7" | ||||
|     lerna: "npm:3.22.1" | ||||
|     lint-staged: "npm:15.4.3" | ||||
|     lint-staged: "npm:15.5.0" | ||||
|     madge: "npm:8.0.0" | ||||
|     node-gyp: "npm:9.4.1" | ||||
|     nodemon: "npm:3.1.9" | ||||
|   | ||||