1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-15 09:04:04 +02:00

Merge branch 'dev' into release-2.7

This commit is contained in:
Laurent Cozic 2022-02-02 19:22:49 +00:00
commit 980190ec09
106 changed files with 1234 additions and 389 deletions

View File

@ -16,7 +16,7 @@ jobs:
days-before-stale: 30 days-before-stale: 30
days-before-close: 7 days-before-close: 7
operations-per-run: 1000 operations-per-run: 1000
exempt-issue-labels: 'good first issue,upstream,backlog,high,medium,spec,cannot reproduce' exempt-issue-labels: 'good first issue,upstream,backlog,high,medium,spec,cannot reproduce,enhancement'
stale-issue-label: 'stale' stale-issue-label: 'stale'
close-issue-message: 'Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, feel free to create a new issue with up-to-date information.' close-issue-message: 'Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, feel free to create a new issue with up-to-date information.'
# Don't process pull requests at all # Don't process pull requests at all

View File

@ -1,43 +0,0 @@
diff --git a/index.js b/index.js
index 85d89900d5fe575dd0c19430209fb1703b03554e..5fa68cc9a4bd2b21a7188bd263262fd9b1604ac6 100644
--- a/index.js
+++ b/index.js
@@ -145,7 +145,8 @@ module.exports = function multimd_table_plugin(md, options) {
colspan, leftToken,
rowspan, upTokens = [],
tableLines, tgroupLines,
- tag, text, range, r, c, b;
+ tag, text, range, r, c, b, t,
+ blockState;
if (startLine + 2 > endLine) { return false; }
@@ -315,18 +316,26 @@ module.exports = function multimd_table_plugin(md, options) {
/* Multiline. Join the text and feed into markdown-it blockParser. */
if (options.multiline && trToken.meta.multiline && trToken.meta.mbounds) {
- text = [ text.trimRight() ];
+ // Pad the text with empty lines to ensure the line number mapping is correct
+ text = new Array(trToken.map[0]).fill('').concat([ text.trimRight() ]);
for (b = 1; b < trToken.meta.mbounds.length; b++) {
/* Line with N bounds has cells indexed from 0 to N-2 */
if (c > trToken.meta.mbounds[b].length - 2) { continue; }
range = [ trToken.meta.mbounds[b][c] + 1, trToken.meta.mbounds[b][c + 1] ];
text.push(state.src.slice.apply(state.src, range).trimRight());
}
- state.md.block.parse(text.join('\n'), state.md, state.env, state.tokens);
+ blockState = new state.md.block.State(text.join('\n'), state.md, state.env, []);
+ blockState.level = trToken.level + 1;
+ // Start tokenizing from the actual content (trToken.map[0])
+ state.md.block.tokenize(blockState, trToken.map[0], blockState.lineMax);
+ for (t = 0; t < blockState.tokens.length; t++) {
+ state.tokens.push(blockState.tokens[t]);
+ }
} else {
token = state.push('inline', '', 0);
token.content = text.trim();
token.map = trToken.map;
+ token.level = trToken.level + 1;
token.children = [];
}

View File

@ -672,7 +672,7 @@ footer .bottom-links-row p {
.news-page img, .news-page img,
.news-item-page img { .news-item-page img {
max-width: 650px; max-width: 100%;
} }
/***************************************************************** /*****************************************************************

View File

@ -18,10 +18,9 @@ There are also a few forks of existing packages under the "fork-*" name.
## Required dependencies ## Required dependencies
- Install node 16+ - https://nodejs.org/en/ - Install Node 16+. On Windows, also install the build tools - https://nodejs.org/en/
- [Enable yarn](https://yarnpkg.com/getting-started/install): `corepack enable` - [Enable Yarn](https://yarnpkg.com/getting-started/install): `corepack enable`
- macOS: Install Cocoapods - `brew install cocoapods`. Apple Silicon [may require libvips](https://github.com/laurent22/joplin/pull/5966#issuecomment-1007158597) - `brew install vips`. - macOS: Install Cocoapods - `brew install cocoapods`. Apple Silicon [may require libvips](https://github.com/laurent22/joplin/pull/5966#issuecomment-1007158597) - `brew install vips`.
- Windows: Install Windows Build Tools - `yarn install -g windows-build-tools --vs2015`
- Linux: Install dependencies - `sudo apt install build-essential libnss3 libsecret-1-dev python rsync` - Linux: Install dependencies - `sudo apt install build-essential libnss3 libsecret-1-dev python rsync`
## Building ## Building

View File

@ -1,50 +1,42 @@
FROM node:16-bullseye ### Build stage
FROM node:16-bullseye AS builder
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y \ && apt-get install -y \
python \ python \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Download the init tool Tini and make it executable for use in the final image
ADD https://github.com/krallin/tini/releases/download/v0.19.0/tini-static /tini
RUN chmod u+x /tini
# Enables Yarn # Enables Yarn
RUN corepack enable RUN corepack enable
RUN echo "Node: $(node --version)" \ RUN echo "Node: $(node --version)"
&& echo "Npm: $(npm --version)" \ RUN echo "Npm: $(npm --version)"
&& echo "Yarn: $(yarn --version)" RUN echo "Yarn: $(yarn --version)"
ARG user=joplin WORKDIR /build
RUN useradd --create-home --shell /bin/bash $user COPY .yarn/plugins ./.yarn/plugins
USER $user COPY .yarn/releases ./.yarn/releases
COPY package.json .
ENV NODE_ENV production COPY .yarnrc.yml .
ENV RUNNING_IN_DOCKER 1 COPY yarn.lock .
EXPOSE ${APP_PORT} COPY gulpfile.js .
COPY tsconfig.json .
WORKDIR /home/$user COPY packages/turndown ./packages/turndown
COPY packages/turndown-plugin-gfm ./packages/turndown-plugin-gfm
RUN mkdir /home/$user/logs \ COPY packages/fork-htmlparser2 ./packages/fork-htmlparser2
&& mkdir /home/$user/.yarn COPY packages/server/package*.json ./packages/server/
COPY packages/fork-sax ./packages/fork-sax
COPY --chown=$user:$user .yarn/patches ./.yarn/patches COPY packages/fork-uslug ./packages/fork-uslug
COPY --chown=$user:$user .yarn/plugins ./.yarn/plugins COPY packages/htmlpack ./packages/htmlpack
COPY --chown=$user:$user .yarn/releases ./.yarn/releases COPY packages/renderer ./packages/renderer
COPY --chown=$user:$user package.json . COPY packages/tools ./packages/tools
COPY --chown=$user:$user .yarnrc.yml . COPY packages/lib ./packages/lib
COPY --chown=$user:$user yarn.lock . COPY packages/server ./packages/server
COPY --chown=$user:$user gulpfile.js .
COPY --chown=$user:$user tsconfig.json .
COPY --chown=$user:$user packages/turndown ./packages/turndown
COPY --chown=$user:$user packages/turndown-plugin-gfm ./packages/turndown-plugin-gfm
COPY --chown=$user:$user packages/fork-htmlparser2 ./packages/fork-htmlparser2
COPY --chown=$user:$user packages/server/package*.json ./packages/server/
COPY --chown=$user:$user packages/fork-sax ./packages/fork-sax
COPY --chown=$user:$user packages/fork-uslug ./packages/fork-uslug
COPY --chown=$user:$user packages/htmlpack ./packages/htmlpack
COPY --chown=$user:$user packages/renderer ./packages/renderer
COPY --chown=$user:$user packages/tools ./packages/tools
COPY --chown=$user:$user packages/lib ./packages/lib
COPY --chown=$user:$user packages/server ./packages/server
# For some reason there's both a .yarn/cache and .yarn/berry/cache that are # For some reason there's both a .yarn/cache and .yarn/berry/cache that are
# being generated, and both have the same content. Not clear why it does this # being generated, and both have the same content. Not clear why it does this
@ -59,10 +51,26 @@ RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
&& yarn cache clean \ && yarn cache clean \
&& rm -rf .yarn/berry && rm -rf .yarn/berry
# Call the command directly, without going via npm: ### Final image
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#cmd FROM node:16-bullseye-slim
WORKDIR "/home/$user/packages/server"
CMD [ "node", "dist/app.js" ] ARG user=joplin
RUN useradd --create-home --shell /bin/bash $user
USER $user
COPY --chown=$user:$user --from=builder /build/packages /home/$user/packages
COPY --chown=$user:$user --from=builder /tini /usr/local/bin/tini
ENV NODE_ENV=production
ENV RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}
# Use Tini to start Joplin Server:
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals
WORKDIR /home/$user/packages/server
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/app.js"]
# Build-time metadata # Build-time metadata
# https://github.com/opencontainers/image-spec/blob/master/annotations.md # https://github.com/opencontainers/image-spec/blob/master/annotations.md

View File

@ -10,6 +10,11 @@ under that directory is licensed under the default license, which is MIT.
* * * * * *
Joplin® is a trademark of Cozic Ltd registered in the European Union, with
filing number 018544315.
* * *
Logo and Icon License Logo and Icon License
The Joplin logos and icons are copyright (c) Laurent Cozic, all rights reserved, The Joplin logos and icons are copyright (c) Laurent Cozic, all rights reserved,

View File

@ -4,7 +4,7 @@
* * * * * *
<img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" /> **Joplin** is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in [Markdown format](#markdown). <img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" /> **Joplin**® is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in [Markdown format](#markdown).
Notes exported from Evernote via .enex files [can be imported](#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported. Notes exported from Evernote via .enex files [can be imported](#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
@ -125,6 +125,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
- Development - Development
- [How to build the apps](https://github.com/laurent22/joplin/blob/dev/BUILD.md) - [How to build the apps](https://github.com/laurent22/joplin/blob/dev/BUILD.md)
- [Writing a technical spec](https://github.com/laurent22/joplin/blob/dev/readme/technical_spec.md)
- [End-to-end encryption spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/e2ee.md) - [End-to-end encryption spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/e2ee.md)
- [Note History spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/history.md) - [Note History spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/history.md)
- [Sync Lock spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/sync_lock.md) - [Sync Lock spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/sync_lock.md)
@ -472,24 +473,6 @@ Notes are sorted by "relevance". Currently it means the notes that contain the r
In the desktop application, press <kbd>Ctrl+P</kbd> or <kbd>Cmd+P</kbd> and type a note title or part of its content to jump to it. Or type <kbd>#</kbd> followed by a tag name, or <kbd>@</kbd> followed by a notebook name. In the desktop application, press <kbd>Ctrl+P</kbd> or <kbd>Cmd+P</kbd> and type a note title or part of its content to jump to it. Or type <kbd>#</kbd> followed by a tag name, or <kbd>@</kbd> followed by a notebook name.
# Privacy
Joplin values your privacy and security by giving you complete control over your information and digital footprint.
Joplin applications do not send any data to any service without your authorisation. Any data that Joplin saves, such as notes or images, are saved to your own device and you are free to delete this data at any time.
Joplin has many modern features, some of which use third-party services. You can disable any or all of these features in the application settings. These features are:
|Feature | Description | Default|
|--------|-------------|--------|
|Auto-update|Joplin periodically connects to GitHub to check for new releases.|Enabled|
|Geo-location|Joplin saves geo-location information in note properties when you create a note.|Enabled|
|Synchronisation|Joplin supports synchronisation of your notes across multiple devices. If you choose to synchronise with a third-party, such as OneDrive, the notes will be sent to your OneDrive account, in which case the third-party privacy policy applies.|Disabled|
Joplin is developed as an open-source application and the source code is freely available online to inspect.
For any question about Joplin privacy, please leave a message on the [Joplin Forum](https://discourse.joplinapp.org/).
# Donations # Donations
Donations to Joplin support the development of the project. Developing quality applications mostly takes time, but there are also some expenses, such as digital certificates to sign the applications, app store fees, hosting, etc. Most of all, your donation will make it possible to keep up the current development standard. Donations to Joplin support the development of the project. Developing quality applications mostly takes time, but there are also some expenses, such as digital certificates to sign the applications, app store fees, hosting, etc. Most of all, your donation will make it possible to keep up the current development standard.

View File

@ -78,8 +78,5 @@
"node-gyp": "^8.4.1", "node-gyp": "^8.4.1",
"nodemon": "^2.0.9" "nodemon": "^2.0.9"
}, },
"packageManager": "yarn@3.1.1", "packageManager": "yarn@3.1.1"
"resolutions": {
"markdown-it-multimd-table@4.1.1": "patch:markdown-it-multimd-table@npm:4.1.1#.yarn/patches/markdown-it-multimd-table-npm-4.1.1-47e334d4bd"
}
} }

View File

@ -8,6 +8,7 @@ export default function useEditorSearch(CodeMirror: any) {
const [scrollbarMarks, setScrollbarMarks] = useState(null); const [scrollbarMarks, setScrollbarMarks] = useState(null);
const [previousKeywordValue, setPreviousKeywordValue] = useState(null); const [previousKeywordValue, setPreviousKeywordValue] = useState(null);
const [previousIndex, setPreviousIndex] = useState(null); const [previousIndex, setPreviousIndex] = useState(null);
const [previousSearchTimestamp, setPreviousSearchTimestamp] = useState(0);
const [overlayTimeout, setOverlayTimeout] = useState(null); const [overlayTimeout, setOverlayTimeout] = useState(null);
const overlayTimeoutRef = useRef(null); const overlayTimeoutRef = useRef(null);
overlayTimeoutRef.current = overlayTimeout; overlayTimeoutRef.current = overlayTimeout;
@ -51,7 +52,7 @@ export default function useEditorSearch(CodeMirror: any) {
// Highlights the currently active found work // Highlights the currently active found work
// It's possible to get tricky with this fucntions and just use findNext/findPrev // It's possible to get tricky with this fucntions and just use findNext/findPrev
// but this is fast enough and works more naturally with the current search logic // but this is fast enough and works more naturally with the current search logic
function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean) { function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean, withSelection: boolean) {
const cursor = cm.getSearchCursor(searchTerm); const cursor = cm.getSearchCursor(searchTerm);
let match: any = null; let match: any = null;
@ -64,7 +65,13 @@ export default function useEditorSearch(CodeMirror: any) {
} }
if (match) { if (match) {
if (scrollTo) cm.scrollIntoView(match); if (scrollTo) {
if (withSelection) {
cm.setSelection(match.from, match.to);
} else {
cm.scrollTo(match);
}
}
return cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' }); return cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' });
} }
@ -90,7 +97,7 @@ export default function useEditorSearch(CodeMirror: any) {
CodeMirror.defineExtension('setMarkers', function(keywords: any, options: any) { CodeMirror.defineExtension('setMarkers', function(keywords: any, options: any) {
if (!options) { if (!options) {
options = { selectedIndex: 0 }; options = { selectedIndex: 0, searchTimestamp: 0 };
} }
clearMarkers(); clearMarkers();
@ -107,16 +114,15 @@ export default function useEditorSearch(CodeMirror: any) {
const searchTerm = getSearchTerm(keyword); const searchTerm = getSearchTerm(keyword);
// We only want to scroll the first keyword into view in the case of a multi keyword search // We only want to scroll the first keyword into view in the case of a multi keyword search
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
// If there is only one choice, scrollTo should be true. The below is a dummy of nMatches === 1.
options.selectedIndex === 0);
const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo); const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
if (match) marks.push(match); if (match) marks.push(match);
} }
setMarkers(marks); setMarkers(marks);
setPreviousIndex(options.selectedIndex); setPreviousIndex(options.selectedIndex);
setPreviousSearchTimestamp(options.searchTimestamp);
// SEARCHOVERLAY // SEARCHOVERLAY
// We only want to highlight all matches when there is only 1 search term // We only want to highlight all matches when there is only 1 search term

View File

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import { SearchMarkers } from './useSearchMarkers'; import { SearchMarkers } from './useSearchMarkers';
const CommandService = require('@joplin/lib/services/CommandService').default;
const logger = Logger.create('useNoteSearchBar'); const logger = Logger.create('useNoteSearchBar');
@ -70,6 +71,7 @@ export default function useNoteSearchBar() {
const onClose = useCallback(() => { const onClose = useCallback(() => {
setShowLocalSearch(false); setShowLocalSearch(false);
setLocalSearch(defaultLocalSearch()); setLocalSearch(defaultLocalSearch());
void CommandService.instance().execute('focusElementNoteBody');
}, []); }, []);
const setResultCount = useCallback((count: number) => { const setResultCount = useCallback((count: number) => {
@ -90,6 +92,7 @@ export default function useNoteSearchBar() {
selectedIndex: localSearch.selectedIndex, selectedIndex: localSearch.selectedIndex,
separateWordSearch: false, separateWordSearch: false,
searchTimestamp: localSearch.timestamp, searchTimestamp: localSearch.timestamp,
withSelection: true,
}, },
keywords: [ keywords: [
{ {

View File

@ -4,6 +4,7 @@ interface SearchMarkersOptions {
searchTimestamp: number; searchTimestamp: number;
selectedIndex: number; selectedIndex: number;
separateWordSearch: boolean; separateWordSearch: boolean;
withSelection?: boolean;
} }
export interface SearchMarkers { export interface SearchMarkers {

View File

@ -277,6 +277,9 @@
let restoreAndRefreshTimeoutID_ = null; let restoreAndRefreshTimeoutID_ = null;
let restoreAndRefreshTimeout_ = Date.now(); let restoreAndRefreshTimeout_ = Date.now();
// If 'noteRenderComplete' message is ongoing, resizing should not trigger a 'percentScroll' messsage.
let noteRenderCompleteMessageIsOngoing_ = false;
// A callback anonymous function invoked when the scroll height changes. // A callback anonymous function invoked when the scroll height changes.
const onRendering = observeRendering((cause, height, heightChanged) => { const onRendering = observeRendering((cause, height, heightChanged) => {
if (!alreadyAllImagesLoaded && !scrollmap.isPresent()) { if (!alreadyAllImagesLoaded && !scrollmap.isPresent()) {
@ -285,6 +288,7 @@
alreadyAllImagesLoaded = true; alreadyAllImagesLoaded = true;
scrollmap.refresh(); scrollmap.refresh();
restorePercentScroll(); restorePercentScroll();
noteRenderCompleteMessageIsOngoing_ = true;
ipcProxySendToHost('noteRenderComplete'); ipcProxySendToHost('noteRenderComplete');
return; return;
} }
@ -294,7 +298,7 @@
scrollmap.refresh(); scrollmap.refresh();
restorePercentScroll(); restorePercentScroll();
// To ensures Editor's scroll position is synced with Viewer's // To ensures Editor's scroll position is synced with Viewer's
ipcProxySendToHost('percentScroll', percentScroll_); if (!noteRenderCompleteMessageIsOngoing_) ipcProxySendToHost('percentScroll', percentScroll_);
}; };
const now = Date.now(); const now = Date.now();
if (now < restoreAndRefreshTimeout_) { if (now < restoreAndRefreshTimeout_) {
@ -345,11 +349,13 @@
if (scrollmap.isPresent()) { if (scrollmap.isPresent()) {
// Now, ready to receive scrollToHash/setPercentScroll from Editor. // Now, ready to receive scrollToHash/setPercentScroll from Editor.
noteRenderCompleteMessageIsOngoing_ = true;
ipcProxySendToHost('noteRenderComplete'); ipcProxySendToHost('noteRenderComplete');
} }
} }
ipc.setPercentScroll = (event) => { ipc.setPercentScroll = (event) => {
noteRenderCompleteMessageIsOngoing_ = false;
setPercentScroll(event.percent); setPercentScroll(event.percent);
} }

View File

@ -1 +1 @@
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiNhYmIyYmY7YmFja2dyb3VuZDojMjgyYzM0fS5obGpzLWtleXdvcmQsLmhsanMtb3BlcmF0b3IsLmhsanMtcGF0dGVybi1tYXRjaHtjb2xvcjojZjkyNjcyfS5obGpzLWZ1bmN0aW9uLC5obGpzLXBhdHRlcm4tbWF0Y2ggLmhsanMtY29uc3RydWN0b3J7Y29sb3I6IzYxYWVlZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXN7Y29sb3I6I2E2ZTIyZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXMgLmhsanMtdHlwaW5ne2NvbG9yOiNmZDk3MWZ9LmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGV7Y29sb3I6IzdlNTdjMn0uaGxqcy1jb25zdHJ1Y3Rvcntjb2xvcjojZTJiOTNkfS5obGpzLWNvbnN0cnVjdG9yIC5obGpzLXN0cmluZ3tjb2xvcjojOWNjYzY1fS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2IxOGViMTtmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYXtjb2xvcjojYzY3OGRkfS5obGpzLWRlbGV0aW9uLC5obGpzLW5hbWUsLmhsanMtc2VjdGlvbiwuaGxqcy1zZWxlY3Rvci10YWcsLmhsanMtc3Vic3R7Y29sb3I6I2UwNmM3NX0uaGxqcy1saXRlcmFse2NvbG9yOiM1NmI2YzJ9LmhsanMtYWRkaXRpb24sLmhsanMtYXR0cmlidXRlLC5obGpzLW1ldGEgLmhsanMtc3RyaW5nLC5obGpzLXJlZ2V4cCwuaGxqcy1zdHJpbmd7Y29sb3I6Izk4YzM3OX0uaGxqcy1idWlsdF9pbiwuaGxqcy1jbGFzcyAuaGxqcy10aXRsZSwuaGxqcy10aXRsZS5jbGFzc197Y29sb3I6I2U2YzA3Yn0uaGxqcy1hdHRyLC5obGpzLW51bWJlciwuaGxqcy1zZWxlY3Rvci1hdHRyLC5obGpzLXNlbGVjdG9yLWNsYXNzLC5obGpzLXNlbGVjdG9yLXBzZXVkbywuaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwuaGxqcy10eXBlLC5obGpzLXZhcmlhYmxle2NvbG9yOiNkMTlhNjZ9LmhsanMtYnVsbGV0LC5obGpzLWxpbmssLmhsanMtbWV0YSwuaGxqcy1zZWxlY3Rvci1pZCwuaGxqcy1zeW1ib2wsLmhsanMtdGl0bGV7Y29sb3I6IzYxYWVlZX0uaGxqcy1lbXBoYXNpc3tmb250LXN0eWxlOml0YWxpY30uaGxqcy1zdHJvbmd7Zm9udC13ZWlnaHQ6NzAwfS5obGpzLWxpbmt7dGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZX0=`; module.exports = `LyoKCkF0b20gT25lIERhcmsgV2l0aCBzdXBwb3J0IGZvciBSZWFzb25NTCBieSBHaWRpIE1vcnJpcywgYmFzZWQgb2ZmIHdvcmsgYnkgRGFuaWVsIEdhbWFnZQoKT3JpZ2luYWwgT25lIERhcmsgU3ludGF4IHRoZW1lIGZyb20gaHR0cHM6Ly9naXRodWIuY29tL2F0b20vb25lLWRhcmstc3ludGF4CgoqLwouaGxqcyB7CiAgY29sb3I6ICNhYmIyYmY7CiAgYmFja2dyb3VuZDogIzI4MmMzNDsKfQouaGxqcy1rZXl3b3JkLCAuaGxqcy1vcGVyYXRvciB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCAuaGxqcy1jb25zdHJ1Y3RvciB7CiAgY29sb3I6ICM2MWFlZWU7Cn0KLmhsanMtZnVuY3Rpb24gewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWZ1bmN0aW9uIC5obGpzLXBhcmFtcyB7CiAgY29sb3I6ICNBNkUyMkU7Cn0KLmhsanMtZnVuY3Rpb24gLmhsanMtcGFyYW1zIC5obGpzLXR5cGluZyB7CiAgY29sb3I6ICNGRDk3MUY7Cn0KLmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGUgewogIGNvbG9yOiAjN2U1N2MyOwp9Ci5obGpzLWNvbnN0cnVjdG9yIHsKICBjb2xvcjogI2UyYjkzZDsKfQouaGxqcy1jb25zdHJ1Y3RvciAuaGxqcy1zdHJpbmcgewogIGNvbG9yOiAjOUNDQzY1Owp9Ci5obGpzLWNvbW1lbnQsIC5obGpzLXF1b3RlIHsKICBjb2xvcjogI2IxOGViMTsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtZG9jdGFnLCAuaGxqcy1mb3JtdWxhIHsKICBjb2xvcjogI2M2NzhkZDsKfQouaGxqcy1zZWN0aW9uLCAuaGxqcy1uYW1lLCAuaGxqcy1zZWxlY3Rvci10YWcsIC5obGpzLWRlbGV0aW9uLCAuaGxqcy1zdWJzdCB7CiAgY29sb3I6ICNlMDZjNzU7Cn0KLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICM1NmI2YzI7Cn0KLmhsanMtc3RyaW5nLCAuaGxqcy1yZWdleHAsIC5obGpzLWFkZGl0aW9uLCAuaGxqcy1hdHRyaWJ1dGUsIC5obGpzLW1ldGEgLmhsanMtc3RyaW5nIHsKICBjb2xvcjogIzk4YzM3OTsKfQouaGxqcy1idWlsdF9pbiwKLmhsanMtdGl0bGUuY2xhc3NfLAouaGxqcy1jbGFzcyAuaGxqcy10aXRsZSB7CiAgY29sb3I6ICNlNmMwN2I7Cn0KLmhsanMtYXR0ciwgLmhsanMtdmFyaWFibGUsIC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLCAuaGxqcy10eXBlLCAuaGxqcy1zZWxlY3Rvci1jbGFzcywgLmhsanMtc2VsZWN0b3ItYXR0ciwgLmhsanMtc2VsZWN0b3ItcHNldWRvLCAuaGxqcy1udW1iZXIgewogIGNvbG9yOiAjZDE5YTY2Owp9Ci5obGpzLXN5bWJvbCwgLmhsanMtYnVsbGV0LCAuaGxqcy1saW5rLCAuaGxqcy1tZXRhLCAuaGxqcy1zZWxlY3Rvci1pZCwgLmhsanMtdGl0bGUgewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWVtcGhhc2lzIHsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQouaGxqcy1saW5rIHsKICB0ZXh0LWRlY29yYXRpb246IHVuZGVybGluZTsKfQo=`;

View File

@ -1 +1 @@
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiMzODNhNDI7YmFja2dyb3VuZDojZmFmYWZhfS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2EwYTFhNztmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYSwuaGxqcy1rZXl3b3Jke2NvbG9yOiNhNjI2YTR9LmhsanMtZGVsZXRpb24sLmhsanMtbmFtZSwuaGxqcy1zZWN0aW9uLC5obGpzLXNlbGVjdG9yLXRhZywuaGxqcy1zdWJzdHtjb2xvcjojZTQ1NjQ5fS5obGpzLWxpdGVyYWx7Y29sb3I6IzAxODRiYn0uaGxqcy1hZGRpdGlvbiwuaGxqcy1hdHRyaWJ1dGUsLmhsanMtbWV0YSAuaGxqcy1zdHJpbmcsLmhsanMtcmVnZXhwLC5obGpzLXN0cmluZ3tjb2xvcjojNTBhMTRmfS5obGpzLWF0dHIsLmhsanMtbnVtYmVyLC5obGpzLXNlbGVjdG9yLWF0dHIsLmhsanMtc2VsZWN0b3ItY2xhc3MsLmhsanMtc2VsZWN0b3ItcHNldWRvLC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLC5obGpzLXR5cGUsLmhsanMtdmFyaWFibGV7Y29sb3I6Izk4NjgwMX0uaGxqcy1idWxsZXQsLmhsanMtbGluaywuaGxqcy1tZXRhLC5obGpzLXNlbGVjdG9yLWlkLC5obGpzLXN5bWJvbCwuaGxqcy10aXRsZXtjb2xvcjojNDA3OGYyfS5obGpzLWJ1aWx0X2luLC5obGpzLWNsYXNzIC5obGpzLXRpdGxlLC5obGpzLXRpdGxlLmNsYXNzX3tjb2xvcjojYzE4NDAxfS5obGpzLWVtcGhhc2lze2ZvbnQtc3R5bGU6aXRhbGljfS5obGpzLXN0cm9uZ3tmb250LXdlaWdodDo3MDB9LmhsanMtbGlua3t0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lfQ==`; module.exports = `LyoKCkF0b20gT25lIExpZ2h0IGJ5IERhbmllbCBHYW1hZ2UKT3JpZ2luYWwgT25lIExpZ2h0IFN5bnRheCB0aGVtZSBmcm9tIGh0dHBzOi8vZ2l0aHViLmNvbS9hdG9tL29uZS1saWdodC1zeW50YXgKCmJhc2U6ICAgICNmYWZhZmEKbW9uby0xOiAgIzM4M2E0Mgptb25vLTI6ICAjNjg2Yjc3Cm1vbm8tMzogICNhMGExYTcKaHVlLTE6ICAgIzAxODRiYgpodWUtMjogICAjNDA3OGYyCmh1ZS0zOiAgICNhNjI2YTQKaHVlLTQ6ICAgIzUwYTE0ZgpodWUtNTogICAjZTQ1NjQ5Cmh1ZS01LTI6ICNjOTEyNDMKaHVlLTY6ICAgIzk4NjgwMQpodWUtNi0yOiAjYzE4NDAxCgoqLwoKLmhsanMgewogIGNvbG9yOiAjMzgzYTQyOwogIGJhY2tncm91bmQ6ICNmYWZhZmE7Cn0KCi5obGpzLWNvbW1lbnQsCi5obGpzLXF1b3RlIHsKICBjb2xvcjogI2EwYTFhNzsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KCi5obGpzLWRvY3RhZywKLmhsanMta2V5d29yZCwKLmhsanMtZm9ybXVsYSB7CiAgY29sb3I6ICNhNjI2YTQ7Cn0KCi5obGpzLXNlY3Rpb24sCi5obGpzLW5hbWUsCi5obGpzLXNlbGVjdG9yLXRhZywKLmhsanMtZGVsZXRpb24sCi5obGpzLXN1YnN0IHsKICBjb2xvcjogI2U0NTY0OTsKfQoKLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICMwMTg0YmI7Cn0KCi5obGpzLXN0cmluZywKLmhsanMtcmVnZXhwLAouaGxqcy1hZGRpdGlvbiwKLmhsanMtYXR0cmlidXRlLAouaGxqcy1tZXRhIC5obGpzLXN0cmluZyB7CiAgY29sb3I6ICM1MGExNGY7Cn0KCi5obGpzLWF0dHIsCi5obGpzLXZhcmlhYmxlLAouaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwKLmhsanMtdHlwZSwKLmhsanMtc2VsZWN0b3ItY2xhc3MsCi5obGpzLXNlbGVjdG9yLWF0dHIsCi5obGpzLXNlbGVjdG9yLXBzZXVkbywKLmhsanMtbnVtYmVyIHsKICBjb2xvcjogIzk4NjgwMTsKfQoKLmhsanMtc3ltYm9sLAouaGxqcy1idWxsZXQsCi5obGpzLWxpbmssCi5obGpzLW1ldGEsCi5obGpzLXNlbGVjdG9yLWlkLAouaGxqcy10aXRsZSB7CiAgY29sb3I6ICM0MDc4ZjI7Cn0KCi5obGpzLWJ1aWx0X2luLAouaGxqcy10aXRsZS5jbGFzc18sCi5obGpzLWNsYXNzIC5obGpzLXRpdGxlIHsKICBjb2xvcjogI2MxODQwMTsKfQoKLmhsanMtZW1waGFzaXMgewogIGZvbnQtc3R5bGU6IGl0YWxpYzsKfQoKLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQoKLmhsanMtbGluayB7CiAgdGV4dC1kZWNvcmF0aW9uOiB1bmRlcmxpbmU7Cn0K`;

View File

@ -1,5 +1,5 @@
module.exports = { module.exports = {
hash:"dd2315568bb7795f97cee26a47e9b82b", files: { hash:"ea13a22d0df59339b671f6b5700e2914", 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-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' }, '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' }, 'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
module.exports = `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`; module.exports = `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`;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1,74 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-keyword,.hljs-operator,.hljs-pattern-match{color:#f92672}.hljs-function,.hljs-pattern-match .hljs-constructor{color:#61aeee}.hljs-function .hljs-params{color:#a6e22e}.hljs-function .hljs-params .hljs-typing{color:#fd971f}.hljs-module-access .hljs-module{color:#7e57c2}.hljs-constructor{color:#e2b93d}.hljs-constructor .hljs-string{color:#9ccc65}.hljs-comment,.hljs-quote{color:#b18eb1;font-style:italic}.hljs-doctag,.hljs-formula{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline} /*
Atom One Dark With support for ReasonML by Gidi Morris, based off work by Daniel Gamage
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
*/
.hljs {
color: #abb2bf;
background: #282c34;
}
.hljs-keyword, .hljs-operator {
color: #F92672;
}
.hljs-pattern-match {
color: #F92672;
}
.hljs-pattern-match .hljs-constructor {
color: #61aeee;
}
.hljs-function {
color: #61aeee;
}
.hljs-function .hljs-params {
color: #A6E22E;
}
.hljs-function .hljs-params .hljs-typing {
color: #FD971F;
}
.hljs-module-access .hljs-module {
color: #7e57c2;
}
.hljs-constructor {
color: #e2b93d;
}
.hljs-constructor .hljs-string {
color: #9CCC65;
}
.hljs-comment, .hljs-quote {
color: #b18eb1;
font-style: italic;
}
.hljs-doctag, .hljs-formula {
color: #c678dd;
}
.hljs-section, .hljs-name, .hljs-selector-tag, .hljs-deletion, .hljs-subst {
color: #e06c75;
}
.hljs-literal {
color: #56b6c2;
}
.hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta .hljs-string {
color: #98c379;
}
.hljs-built_in,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #e6c07b;
}
.hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-type, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-number {
color: #d19a66;
}
.hljs-symbol, .hljs-bullet, .hljs-link, .hljs-meta, .hljs-selector-id, .hljs-title {
color: #61aeee;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}

View File

@ -1 +1,94 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline} /*
Atom One Light by Daniel Gamage
Original One Light Syntax theme from https://github.com/atom/one-light-syntax
base: #fafafa
mono-1: #383a42
mono-2: #686b77
mono-3: #a0a1a7
hue-1: #0184bb
hue-2: #4078f2
hue-3: #a626a4
hue-4: #50a14f
hue-5: #e45649
hue-5-2: #c91243
hue-6: #986801
hue-6-2: #c18401
*/
.hljs {
color: #383a42;
background: #fafafa;
}
.hljs-comment,
.hljs-quote {
color: #a0a1a7;
font-style: italic;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #a626a4;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #e45649;
}
.hljs-literal {
color: #0184bb;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string {
color: #50a14f;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-type,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #986801;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-title {
color: #4078f2;
}
.hljs-built_in,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #c18401;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -126,6 +126,7 @@ export default function(theme: any, options: Options = null) {
} }
h3 { h3 {
font-size: 1.1em; font-size: 1.1em;
font-weight: bold;
} }
h4, h5, h6 { h4, h5, h6 {
font-size: 1em; font-size: 1em;

View File

@ -41,12 +41,12 @@
"markdown-it-footnote": "^3.0.2", "markdown-it-footnote": "^3.0.2",
"markdown-it-ins": "^3.0.0", "markdown-it-ins": "^3.0.0",
"markdown-it-mark": "^3.0.0", "markdown-it-mark": "^3.0.0",
"markdown-it-multimd-table": "^4.0.1", "markdown-it-multimd-table": "^4.1.2",
"markdown-it-sub": "^1.0.0", "markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0", "markdown-it-sup": "^1.0.0",
"markdown-it-toc-done-right": "^4.1.0", "markdown-it-toc-done-right": "^4.1.0",
"md5": "^2.2.1", "md5": "^2.2.1",
"mermaid": "^8.13.5" "mermaid": "^8.13.9"
}, },
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14" "gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
} }

View File

@ -3,7 +3,16 @@
} }
html { html {
font-size: 100%; font-size: 14px;
}
.is-admin-page div.main-container,
.is-admin-page div.navbar-container {
max-width: none !important;
}
div.navbar-container {
padding: 0 3rem;
} }
input.form-control { input.form-control {
@ -14,17 +23,16 @@ input.form-control {
align-items: center; align-items: center;
} }
.navbar .logo-container .navbar-appname {
margin-left: 15px;
}
.navbar .logo-text { .navbar .logo-text {
font-size: 2.2em; font-size: 2.2em;
font-weight: bold; font-weight: bold;
margin-left: 0.5em; margin-left: 0.5em;
} }
/*
.navbar .logo {
height: 50px;
} */
.navbar .navbar-item img { .navbar .navbar-item img {
max-height: 3em; max-height: 3em;
} }

Binary file not shown.

View File

@ -106,6 +106,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
const supportEmail = env.SUPPORT_EMAIL; const supportEmail = env.SUPPORT_EMAIL;
config_ = { config_ = {
...env,
appVersion: packageJson.version, appVersion: packageJson.version,
appName, appName,
isJoplinCloud: apiBaseUrl.includes('.joplincloud.com') || apiBaseUrl.includes('.joplincloud.local'), isJoplinCloud: apiBaseUrl.includes('.joplincloud.com') || apiBaseUrl.includes('.joplincloud.local'),

View File

@ -25,12 +25,13 @@ describe('db', function() {
const ignoreAllBefore = '20210819165350_user_flags'; const ignoreAllBefore = '20210819165350_user_flags';
// Some migrations produce no changes visible to sql-ts, in particular // Some migrations produce no changes visible to sql-ts, in particular
// when the migration only adds a constraint or an index. In this case // when the migration only adds a constraint or an index, or when a
// we skip the migration. Ideally we should test these too but for now // default is changed. In this case we skip the migration. Ideally we
// that will do. // should test these too but for now that will do.
const doNoCheckUpgrade = [ const doNoCheckUpgrade = [
'20211030103016_item_owner_name_unique', '20211030103016_item_owner_name_unique',
'20211111134329_storage_index', '20211111134329_storage_index',
'20220121172409_email_recipient_default',
]; ];
let startProcessing = false; let startProcessing = false;

View File

@ -89,6 +89,13 @@ const defaultEnvValues: EnvVariables = {
STRIPE_SECRET_KEY: '', STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '', STRIPE_WEBHOOK_SECRET: '',
// ==================================================
// User data deletion
// ==================================================
USER_DATA_AUTO_DELETE_ENABLED: false,
USER_DATA_AUTO_DELETE_AFTER_DAYS: 90,
}; };
export interface EnvVariables { export interface EnvVariables {
@ -138,6 +145,9 @@ export interface EnvVariables {
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string; STRIPE_WEBHOOK_SECRET: string;
USER_DATA_AUTO_DELETE_ENABLED: boolean;
USER_DATA_AUTO_DELETE_AFTER_DAYS: number;
} }
const parseBoolean = (s: string): boolean => { const parseBoolean = (s: string): boolean => {

View File

@ -0,0 +1,20 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
// Email recipient_id was incorrectly set to "0" by default. This migration set
// it to an empty string by default, and update all rows that have "0" as
// recipient_id.
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('emails', (table: Knex.CreateTableBuilder) => {
table.string('recipient_id', 32).defaultTo('').notNullable().alter();
});
await db('emails').update({ recipient_id: '' }).where('recipient_id', '=', '0');
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.alterTable('emails', (table: Knex.CreateTableBuilder) => {
table.string('recipient_id', 32).defaultTo(0).notNullable().alter();
});
}

View File

@ -0,0 +1,54 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
// It's assumed that the input user IDs are disabled.
// The disabled_time will be set to the first flag created_time
export const setUserAccountDisabledTimes = async (db: DbConnection, userIds: string[]) => {
// FailedPaymentFinal = 2,
// SubscriptionCancelled = 5,
// ManuallyDisabled = 6,
// UserDeletionInProgress = 7,
interface UserFlag {
user_id: string;
created_time: number;
}
const flags: UserFlag[] = await db('user_flags')
.select(['user_id', 'created_time'])
.whereIn('user_id', userIds)
.whereIn('type', [2, 5, 6, 7])
.orderBy('created_time', 'asc');
for (const userId of userIds) {
const flag = flags.find(f => f.user_id === userId);
if (!flag) {
console.warn(`Found a disabled account without an associated flag. Setting disabled timestamp to current time: ${userId}`);
}
await db('users')
.update({ disabled_time: flag ? flag.created_time : Date.now() })
.where('id', '=', userId);
}
};
export const disabledUserIds = async (db: DbConnection): Promise<string[]> => {
const users = await db('users').select(['id']).where('enabled', '=', 0);
return users.map(u => u.id);
};
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => {
table.bigInteger('disabled_time').defaultTo(0).notNullable();
});
const userIds = await disabledUserIds(db);
await setUserAccountDisabledTimes(db, userIds);
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => {
table.dropColumn('disabled_time');
});
}

View File

@ -0,0 +1,17 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('backup_items', (table: Knex.CreateTableBuilder) => {
table.increments('id').unique().primary().notNullable();
table.integer('type').notNullable();
table.text('key', 'mediumtext').notNullable();
table.string('user_id', 32).defaultTo('').notNullable();
table.binary('content').notNullable();
table.bigInteger('created_time').notNullable();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('backup_items');
}

View File

@ -0,0 +1,72 @@
import { UserFlagType } from '../../services/database/types';
import { beforeAllDb, afterAllTests, beforeEachDb, createUser, models, db } from '../../utils/testing/testUtils';
import { disabledUserIds, setUserAccountDisabledTimes } from '../20220131185922_account_disabled_timestamp';
describe('20220131185922_account_disabled_timestamp', function() {
beforeAll(async () => {
await beforeAllDb('20220131185922_account_disabled_timestamp');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should set the user account disabled time', async function() {
const user1 = await createUser(1);
const user2 = await createUser(2);
const user3 = await createUser(3);
const user4 = await createUser(4);
jest.useFakeTimers('modern');
// -------------------------------------------------
// User 1
// -------------------------------------------------
const t0 = new Date('2021-12-14').getTime();
jest.setSystemTime(t0);
await models().userFlag().add(user1.id, UserFlagType.AccountOverLimit);
// -------------------------------------------------
// User 2
// -------------------------------------------------
const t1 = new Date('2021-12-15').getTime();
jest.setSystemTime(t1);
await models().userFlag().add(user2.id, UserFlagType.FailedPaymentFinal);
const t2 = new Date('2021-12-16').getTime();
jest.setSystemTime(t2);
await models().userFlag().add(user2.id, UserFlagType.ManuallyDisabled);
// -------------------------------------------------
// User 3
// -------------------------------------------------
const t3 = new Date('2021-12-17').getTime();
jest.setSystemTime(t3);
await models().userFlag().add(user3.id, UserFlagType.SubscriptionCancelled);
const userIds = await disabledUserIds(db());
expect(userIds.sort()).toEqual([user2.id, user3.id].sort());
await setUserAccountDisabledTimes(db(), userIds);
expect((await models().user().load(user1.id)).disabled_time).toBe(0);
expect((await models().user().load(user2.id)).disabled_time).toBe(t1);
expect((await models().user().load(user3.id)).disabled_time).toBe(t3);
expect((await models().user().load(user4.id)).disabled_time).toBe(0);
jest.useRealTimers();
});
});

View File

@ -0,0 +1,29 @@
import { BackupItem, BackupItemType } from '../services/database/types';
import BaseModel from './BaseModel';
export default class BackupItemModel extends BaseModel<BackupItem> {
protected get tableName(): string {
return 'backup_items';
}
protected hasUuid(): boolean {
return false;
}
protected hasUpdatedTime(): boolean {
return false;
}
public async add(type: BackupItemType, key: string, content: any, userId: string = ''): Promise<BackupItem> {
const item: BackupItem = {
user_id: userId,
key,
type,
content,
};
return this.save(item);
}
}

View File

@ -10,6 +10,7 @@ import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/pe
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import dbuuid from '../utils/dbuuid'; import dbuuid from '../utils/dbuuid';
import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination'; import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination';
import { unique } from '../utils/array';
const logger = Logger.create('BaseModel'); const logger = Logger.create('BaseModel');
@ -165,6 +166,10 @@ export default abstract class BaseModel<T> {
return true; return true;
} }
protected hasUpdatedTime(): boolean {
return this.autoTimestampEnabled();
}
protected get hasParentId(): boolean { protected get hasParentId(): boolean {
return false; return false;
} }
@ -314,7 +319,7 @@ export default abstract class BaseModel<T> {
if (isNew) { if (isNew) {
(toSave as WithDates).created_time = timestamp; (toSave as WithDates).created_time = timestamp;
} }
(toSave as WithDates).updated_time = timestamp; if (this.hasUpdatedTime()) (toSave as WithDates).updated_time = timestamp;
} }
if (options.skipValidation !== true) object = await this.validate(object, { isNew: isNew, rules: options.validationRules ? options.validationRules : {} }); if (options.skipValidation !== true) object = await this.validate(object, { isNew: isNew, rules: options.validationRules ? options.validationRules : {} });
@ -339,6 +344,7 @@ export default abstract class BaseModel<T> {
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> { public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
if (!ids.length) return []; if (!ids.length) return [];
ids = unique(ids);
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids); return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
} }

View File

@ -1,4 +1,5 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models, createUser, expectThrow } from '../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, models, createUser, expectThrow } from '../utils/testing/testUtils';
import { Day } from '../utils/time';
describe('UserDeletionModel', function() { describe('UserDeletionModel', function() {
@ -143,4 +144,39 @@ describe('UserDeletionModel', function() {
jest.useRealTimers(); jest.useRealTimers();
}); });
test('should auto-add users for deletion', async function() {
jest.useFakeTimers('modern');
const t0 = new Date('2022-02-22').getTime();
jest.setSystemTime(t0);
await createUser(1);
const user2 = await createUser(2);
await models().user().save({
id: user2.id,
enabled: 0,
disabled_time: t0,
});
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(0);
const t1 = new Date('2022-05-30').getTime();
jest.setSystemTime(t1);
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(1);
const d = (await models().userDeletion().all())[0];
expect(d.user_id).toBe(user2.id);
// Shouldn't add it again if running autoAdd() again
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(1);
jest.useRealTimers();
});
}); });

View File

@ -1,4 +1,4 @@
import { UserDeletion, Uuid } from '../services/database/types'; import { User, UserDeletion, Uuid } from '../services/database/types';
import { errorToString } from '../utils/errors'; import { errorToString } from '../utils/errors';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
@ -7,6 +7,14 @@ export interface AddOptions {
processAccount?: boolean; processAccount?: boolean;
} }
const defaultAddOptions = () => {
const d: AddOptions = {
processAccount: true,
processData: true,
};
return d;
};
export default class UserDeletionModel extends BaseModel<UserDeletion> { export default class UserDeletionModel extends BaseModel<UserDeletion> {
protected get tableName(): string { protected get tableName(): string {
@ -28,8 +36,7 @@ export default class UserDeletionModel extends BaseModel<UserDeletion> {
public async add(userId: Uuid, scheduledTime: number, options: AddOptions = null): Promise<UserDeletion> { public async add(userId: Uuid, scheduledTime: number, options: AddOptions = null): Promise<UserDeletion> {
options = { options = {
processAccount: true, ...defaultAddOptions(),
processData: true,
...options, ...options,
}; };
@ -91,4 +98,26 @@ export default class UserDeletionModel extends BaseModel<UserDeletion> {
.where('id', deletionId); .where('id', deletionId);
} }
public async autoAdd(maxAutoAddedAccounts: number, ttl: number, scheduledTime: number, options: AddOptions = null): Promise<Uuid[]> {
const cutOffTime = Date.now() - ttl;
const disabledUsers: User[] = await this.db('users')
.select(['users.id'])
.leftJoin('user_deletions', 'users.id', 'user_deletions.user_id')
.where('users.enabled', '=', 0)
.where('users.disabled_time', '<', cutOffTime)
.whereNull('user_deletions.user_id') // Only add users not already in the user_deletions table
.limit(maxAutoAddedAccounts);
const userIds = disabledUsers.map(d => d.id);
await this.withTransaction(async () => {
for (const userId of userIds) {
await this.add(userId, scheduledTime, options);
}
}, 'UserDeletionModel::autoAdd');
return userIds;
}
} }

View File

@ -39,4 +39,17 @@ describe('UserFlagModel', function() {
expect(flag.id).not.toBe(differentFlag.id); expect(flag.id).not.toBe(differentFlag.id);
}); });
test('should set the timestamp when disabling an account', async function() {
const { user } = await createUserAndSession(1);
const beforeTime = Date.now();
await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);
expect((await models().user().load(user.id)).disabled_time).toBeGreaterThanOrEqual(beforeTime);
await models().userFlag().remove(user.id, UserFlagType.FailedPaymentFinal);
expect((await models().user().load(user.id)).disabled_time).toBe(0);
});
}); });

View File

@ -138,6 +138,10 @@ export default class UserFlagModels extends BaseModel<UserFlag> {
newProps.enabled = 0; newProps.enabled = 0;
} }
if (user.enabled !== newProps.enabled) {
newProps.disabled_time = !newProps.enabled ? Date.now() : 0;
}
if (user.can_upload !== newProps.can_upload || user.enabled !== newProps.enabled) { if (user.can_upload !== newProps.can_upload || user.enabled !== newProps.enabled) {
await this.models().user().save({ await this.models().user().save({
id: userId, id: userId,

View File

@ -75,6 +75,7 @@ import { Config } from '../utils/types';
import LockModel from './LockModel'; import LockModel from './LockModel';
import StorageModel from './StorageModel'; import StorageModel from './StorageModel';
import UserDeletionModel from './UserDeletionModel'; import UserDeletionModel from './UserDeletionModel';
import BackupItemModel from './BackupItemModel';
export type NewModelFactoryHandler = (db: DbConnection)=> Models; export type NewModelFactoryHandler = (db: DbConnection)=> Models;
@ -170,6 +171,10 @@ export class Models {
return new UserDeletionModel(this.db_, this.newModelFactory, this.config_); return new UserDeletionModel(this.db_, this.newModelFactory, this.config_);
} }
public backupItem() {
return new BackupItemModel(this.db_, this.newModelFactory, this.config_);
}
} }
export default function newModelFactory(db: DbConnection, config: Config): Models { export default function newModelFactory(db: DbConnection, config: Config): Models {

View File

@ -0,0 +1,31 @@
/* eslint-disable import/prefer-default-export */
import config from '../../config';
import { EmailSender } from '../../services/database/types';
interface Participant {
name: string;
email: string;
}
const senders_: Record<number, Participant> = {};
export const senderInfo = (senderId: EmailSender): Participant => {
if (!senders_[senderId]) {
if (senderId === EmailSender.NoReply) {
senders_[senderId] = {
name: config().mailer.noReplyName,
email: config().mailer.noReplyEmail,
};
} else if (senderId === EmailSender.Support) {
senders_[senderId] = {
name: config().supportName,
email: config().supportEmail,
};
} else {
throw new Error(`Invalid sender ID: ${senderId}`);
}
}
return senders_[senderId];
};

View File

@ -0,0 +1,136 @@
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import defaultView from '../../utils/defaultView';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
import { formatDateTime } from '../../utils/time';
import { adminEmailsUrl, adminEmailUrl, adminUserUrl } from '../../utils/urlUtils';
import { createCsrfTag } from '../../utils/csrf';
import { senderInfo } from '../../models/utils/email';
import { _ } from '@joplin/lib/locale';
import { View } from '../../services/MustacheService';
import { markdownBodyToHtml } from '../../services/email/utils';
const router: Router = new Router(RouteType.Web);
router.get('admin/emails', async (_path: SubPath, ctx: AppContext) => {
const models = ctx.joplin.models;
const pagination = makeTablePagination(ctx.query, 'created_time', PaginationOrderDir.DESC);
const page = await models.email().allPaginated(pagination);
const users = await models.user().loadByIds(page.items.map(e => e.recipient_name));
const table: Table = {
baseUrl: adminEmailsUrl(),
requestQuery: ctx.query,
pageCount: page.page_count,
pagination,
headers: [
{
name: 'id',
label: 'ID',
},
{
name: 'sender_id',
label: 'From',
},
{
name: 'recipient_name',
label: 'To',
},
{
name: 'user_id',
label: 'User',
},
{
name: 'subject',
label: 'Subject',
},
{
name: 'created_time',
label: 'Created',
},
{
name: 'sent_time',
label: 'Sent',
},
{
name: 'error',
label: 'Error',
},
],
rows: page.items.map(d => {
const sender = senderInfo(d.sender_id);
const senderName = sender.name || sender.email || `Sender ${d.sender_id.toString()}`;
let error = '';
if (d.sent_time && !d.sent_success) {
error = d.error ? d.error : '(Unspecified error)';
}
const row: Row = [
{
value: d.id.toString(),
},
{
value: senderName,
url: sender.email ? `mailto:${escape(sender.email)}` : '',
},
{
value: d.recipient_name || d.recipient_email,
url: `mailto:${escape(d.recipient_email)}`,
},
{
value: d.recipient_id ? (users.find(u => u.id === d.recipient_id)?.email || '(not set)') : '-',
url: d.recipient_id ? adminUserUrl(d.recipient_id) : '',
},
{
value: d.subject,
url: adminEmailUrl(d.id),
},
{
value: formatDateTime(d.created_time),
},
{
value: formatDateTime(d.sent_time),
},
{
value: error,
},
];
return row;
}),
};
const view: View = {
...defaultView('admin/emails', _('Emails')),
content: {
emailTable: makeTableView(table),
csrfTag: await createCsrfTag(ctx),
},
};
return view;
});
router.get('admin/emails/:id', async (path: SubPath, ctx: AppContext) => {
const models = ctx.joplin.models;
const email = await models.email().load(path.id);
const view: View = {
...defaultView('admin/email', _('Email')),
content: {
email,
sender: senderInfo(email.sender_id),
bodyHtml: markdownBodyToHtml(email.body),
},
};
return view;
});
export default router;

View File

@ -23,8 +23,6 @@ router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
const page = await ctx.joplin.models.userDeletion().allPaginated(pagination); const page = await ctx.joplin.models.userDeletion().allPaginated(pagination);
const users = await ctx.joplin.models.user().loadByIds(page.items.map(d => d.user_id), { fields: ['id', 'email'] }); const users = await ctx.joplin.models.user().loadByIds(page.items.map(d => d.user_id), { fields: ['id', 'email'] });
console.info(page);
const table: Table = { const table: Table = {
baseUrl: adminUserDeletionsUrl(), baseUrl: adminUserDeletionsUrl(),
requestQuery: ctx.query, requestQuery: ctx.query,

View File

@ -13,6 +13,7 @@ import apiShareUsers from './api/share_users';
import apiUsers from './api/users'; import apiUsers from './api/users';
import adminDashboard from './admin/dashboard'; import adminDashboard from './admin/dashboard';
import adminEmails from './admin/emails';
import adminTasks from './admin/tasks'; import adminTasks from './admin/tasks';
import adminUserDeletions from './admin/user_deletions'; import adminUserDeletions from './admin/user_deletions';
import adminUsers from './admin/users'; import adminUsers from './admin/users';
@ -49,6 +50,7 @@ const routes: Routers = {
'api/users': apiUsers, 'api/users': apiUsers,
'admin/dashboard': adminDashboard, 'admin/dashboard': adminDashboard,
'admin/emails': adminEmails,
'admin/tasks': adminTasks, 'admin/tasks': adminTasks,
'admin/user_deletions': adminUserDeletions, 'admin/user_deletions': adminUserDeletions,
'admin/users': adminUsers, 'admin/users': adminUsers,

View File

@ -8,14 +8,10 @@ import { errorToString } from '../utils/errors';
import EmailModel from '../models/EmailModel'; import EmailModel from '../models/EmailModel';
import { markdownBodyToHtml, markdownBodyToPlainText } from './email/utils'; import { markdownBodyToHtml, markdownBodyToPlainText } from './email/utils';
import { MailerSecurity } from '../env'; import { MailerSecurity } from '../env';
import { senderInfo } from '../models/utils/email';
const logger = Logger.create('EmailService'); const logger = Logger.create('EmailService');
interface Participant {
name: string;
email: string;
}
export default class EmailService extends BaseService { export default class EmailService extends BaseService {
private transport_: any; private transport_: any;
@ -23,7 +19,7 @@ export default class EmailService extends BaseService {
private async transport(): Promise<Mail> { private async transport(): Promise<Mail> {
if (!this.transport_) { if (!this.transport_) {
try { try {
if (!this.senderInfo(EmailSender.NoReply).email) { if (!senderInfo(EmailSender.NoReply).email) {
throw new Error('No-reply email must be set for email service to work (Set env variable MAILER_NOREPLY_EMAIL)'); throw new Error('No-reply email must be set for email service to work (Set env variable MAILER_NOREPLY_EMAIL)');
} }
@ -58,24 +54,6 @@ export default class EmailService extends BaseService {
return this.transport_; return this.transport_;
} }
private senderInfo(senderId: EmailSender): Participant {
if (senderId === EmailSender.NoReply) {
return {
name: this.config.mailer.noReplyName,
email: this.config.mailer.noReplyEmail,
};
}
if (senderId === EmailSender.Support) {
return {
name: this.config.supportName,
email: this.config.supportEmail,
};
}
throw new Error(`Invalid sender ID: ${senderId}`);
}
private escapeEmailField(f: string): string { private escapeEmailField(f: string): string {
return f.replace(/[\n\r"<>]/g, ''); return f.replace(/[\n\r"<>]/g, '');
} }
@ -99,7 +77,7 @@ export default class EmailService extends BaseService {
const transport = await this.transport(); const transport = await this.transport();
for (const email of emails) { for (const email of emails) {
const sender = this.senderInfo(email.sender_id); const sender = senderInfo(email.sender_id);
const mailOptions: Mail.Options = { const mailOptions: Mail.Options = {
from: this.formatNameAndEmail(sender.email, sender.name), from: this.formatNameAndEmail(sender.email, sender.name),

View File

@ -9,7 +9,7 @@ import { makeUrl, UrlType } from '../utils/routeUtils';
import MarkdownIt = require('markdown-it'); import MarkdownIt = require('markdown-it');
import { headerAnchor } from '@joplin/renderer'; import { headerAnchor } from '@joplin/renderer';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { adminDashboardUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils'; import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
import { URL } from 'url'; import { URL } from 'url';
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean; type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
@ -151,6 +151,10 @@ export default class MustacheService {
title: _('Tasks'), title: _('Tasks'),
url: adminTasksUrl(), url: adminTasksUrl(),
}, },
{
title: _('Emails'),
url: adminEmailsUrl(),
},
], ],
}, },
]; ];
@ -278,10 +282,15 @@ export default class MustacheService {
throw new Error(`Unsupported view extension: ${ext}`); throw new Error(`Unsupported view extension: ${ext}`);
} }
private formatPageName(name: string): string {
return name.replace(/[/\\]/g, '-');
}
public async renderView(view: View, globalParams: GlobalParams = null): Promise<string> { public async renderView(view: View, globalParams: GlobalParams = null): Promise<string> {
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []); const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []); const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
const filePath = await this.viewFilePath(view.path); const filePath = await this.viewFilePath(view.path);
const isAdminPage = view.path.startsWith('/admin/');
globalParams = { globalParams = {
...this.defaultLayoutOptions, ...this.defaultLayoutOptions,
@ -289,7 +298,7 @@ export default class MustacheService {
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null, adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null,
navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false), navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false),
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null), userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
isAdminPage: view.path.startsWith('/admin/'), isAdminPage,
s: { s: {
home: _('Home'), home: _('Home'),
users: _('Users'), users: _('Users'),
@ -306,7 +315,7 @@ export default class MustacheService {
const layoutView: any = { const layoutView: any = {
global: globalParams, global: globalParams,
pageName: view.name, pageName: this.formatPageName(view.name),
pageTitle: view.titleOverride ? view.title : `${config().appName} - ${view.title}`, pageTitle: view.titleOverride ? view.title : `${config().appName} - ${view.title}`,
contentHtml: contentHtml, contentHtml: contentHtml,
cssFiles: cssFiles, cssFiles: cssFiles,

View File

@ -4,6 +4,7 @@ import { Config, Env } from '../utils/types';
import BaseService from './BaseService'; import BaseService from './BaseService';
import { Event, EventType } from './database/types'; import { Event, EventType } from './database/types';
import { Services } from './types'; import { Services } from './types';
import { _ } from '@joplin/lib/locale';
const cron = require('node-cron'); const cron = require('node-cron');
const logger = Logger.create('TaskService'); const logger = Logger.create('TaskService');
@ -17,6 +18,7 @@ export enum TaskId {
DeleteExpiredSessions = 6, DeleteExpiredSessions = 6,
CompressOldChanges = 7, CompressOldChanges = 7,
ProcessUserDeletions = 8, ProcessUserDeletions = 8,
AutoAddDisabledAccountsForDeletion = 9,
} }
export enum RunType { export enum RunType {
@ -24,6 +26,25 @@ export enum RunType {
Manual = 2, Manual = 2,
} }
export const taskIdToLabel = (taskId: TaskId): string => {
const strings: Record<TaskId, string> = {
[TaskId.DeleteExpiredTokens]: _('Delete expired tokens'),
[TaskId.UpdateTotalSizes]: _('Update total sizes'),
[TaskId.HandleOversizedAccounts]: _('Process oversized accounts'),
[TaskId.HandleBetaUserEmails]: 'Process beta user emails',
[TaskId.HandleFailedPaymentSubscriptions]: _('Process failed payment subscriptions'),
[TaskId.DeleteExpiredSessions]: _('Delete expired sessions'),
[TaskId.CompressOldChanges]: _('Compress old changes'),
[TaskId.ProcessUserDeletions]: _('Process user deletions'),
[TaskId.AutoAddDisabledAccountsForDeletion]: _('Auto-add disabled accounts for deletion'),
};
const s = strings[taskId];
if (!s) throw new Error(`No such task: ${taskId}`);
return s;
};
const runTypeToString = (runType: RunType) => { const runTypeToString = (runType: RunType) => {
if (runType === RunType.Scheduled) return 'scheduled'; if (runType === RunType.Scheduled) return 'scheduled';
if (runType === RunType.Manual) return 'manual'; if (runType === RunType.Manual) return 'manual';

View File

@ -2,6 +2,7 @@ import config from '../config';
import { shareFolderWithUser } from '../utils/testing/shareApiUtils'; import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
import { afterAllTests, beforeAllDb, beforeEachDb, createNote, createUserAndSession, models } from '../utils/testing/testUtils'; import { afterAllTests, beforeAllDb, beforeEachDb, createNote, createUserAndSession, models } from '../utils/testing/testUtils';
import { Env } from '../utils/types'; import { Env } from '../utils/types';
import { BackupItemType } from './database/types';
import UserDeletionService from './UserDeletionService'; import UserDeletionService from './UserDeletionService';
const newService = () => { const newService = () => {
@ -70,6 +71,8 @@ describe('UserDeletionService', function() {
expect(await models().user().count()).toBe(2); expect(await models().user().count()).toBe(2);
expect(await models().session().count()).toBe(2); expect(await models().session().count()).toBe(2);
const beforeTime = Date.now();
const service = newService(); const service = newService();
await service.processDeletionJob(job, { sleepBetweenOperations: 0 }); await service.processDeletionJob(job, { sleepBetweenOperations: 0 });
@ -78,6 +81,18 @@ describe('UserDeletionService', function() {
const user = (await models().user().all())[0]; const user = (await models().user().all())[0];
expect(user.id).toBe(user2.id); expect(user.id).toBe(user2.id);
const backupItems = await models().backupItem().all();
expect(backupItems.length).toBe(1);
const backupItem = backupItems[0];
expect(backupItem.key).toBe(user1.email);
expect(backupItem.type).toBe(BackupItemType.UserAccount);
expect(backupItem.created_time).toBeGreaterThanOrEqual(beforeTime);
const content = JSON.parse(backupItem.content.toString());
expect(content.user.id).toBe(user1.id);
expect(content.user.email).toBe(user1.email);
expect(content.flags.length).toBe(0);
}); });
test('should not delete notebooks that are not owned', async function() { test('should not delete notebooks that are not owned', async function() {

View File

@ -1,8 +1,8 @@
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import { Pagination } from '../models/utils/pagination'; import { Pagination } from '../models/utils/pagination';
import { msleep } from '../utils/time'; import { Day, msleep } from '../utils/time';
import BaseService from './BaseService'; import BaseService from './BaseService';
import { UserDeletion, UserFlagType, Uuid } from './database/types'; import { BackupItemType, UserDeletion, UserFlagType, Uuid } from './database/types';
const logger = Logger.create('UserDeletionService'); const logger = Logger.create('UserDeletionService');
@ -59,6 +59,21 @@ export default class UserDeletionService extends BaseService {
private async deleteUserAccount(userId: Uuid, _options: DeletionJobOptions = null) { private async deleteUserAccount(userId: Uuid, _options: DeletionJobOptions = null) {
logger.info(`Deleting user account: ${userId}`); logger.info(`Deleting user account: ${userId}`);
const user = await this.models.user().load(userId);
if (!user) throw new Error(`No such user: ${userId}`);
const flags = await this.models.userFlag().allByUserId(userId);
await this.models.backupItem().add(
BackupItemType.UserAccount,
user.email,
JSON.stringify({
user,
flags,
}),
userId
);
await this.models.userFlag().add(userId, UserFlagType.UserDeletionInProgress); await this.models.userFlag().add(userId, UserFlagType.UserDeletionInProgress);
await this.models.session().deleteByUserId(userId); await this.models.session().deleteByUserId(userId);
@ -93,6 +108,24 @@ export default class UserDeletionService extends BaseService {
logger.info('Completed user deletion: ', deletion.id); logger.info('Completed user deletion: ', deletion.id);
} }
public async autoAddForDeletion() {
const addedUserIds = await this.models.userDeletion().autoAdd(
10,
this.config.USER_DATA_AUTO_DELETE_AFTER_DAYS * Day,
3 * Day,
{
processAccount: true,
processData: true,
}
);
if (addedUserIds.length) {
logger.info(`autoAddForDeletion: Queued ${addedUserIds.length} users for deletions: ${addedUserIds.join(', ')}`);
} else {
logger.info('autoAddForDeletion: No users were queued for deletion');
}
}
public async processNextDeletionJob() { public async processNextDeletionJob() {
const deletion = await this.models.userDeletion().next(); const deletion = await this.models.userDeletion().next();
if (!deletion) return; if (!deletion) return;

View File

@ -33,6 +33,10 @@ export enum EventType {
TaskCompleted = 2, TaskCompleted = 2,
} }
export enum BackupItemType {
UserAccount = 1,
}
export enum UserFlagType { export enum UserFlagType {
FailedPaymentWarning = 1, FailedPaymentWarning = 1,
FailedPaymentFinal = 2, FailedPaymentFinal = 2,
@ -87,6 +91,10 @@ export interface WithDates {
created_time?: number; created_time?: number;
} }
export interface WithCreatedDate {
created_time?: number;
}
export interface WithUuid { export interface WithUuid {
id?: Uuid; id?: Uuid;
} }
@ -186,20 +194,6 @@ export interface Change extends WithDates, WithUuid {
user_id?: Uuid; user_id?: Uuid;
} }
export interface Email extends WithDates {
id?: number;
recipient_name?: string;
recipient_email?: string;
recipient_id?: Uuid;
sender_id?: EmailSender;
subject?: string;
body?: string;
sent_time?: number;
sent_success?: number;
error?: string;
key?: string;
}
export interface Token extends WithDates { export interface Token extends WithDates {
id?: number; id?: number;
value?: string; value?: string;
@ -233,6 +227,7 @@ export interface User extends WithDates, WithUuid {
max_total_item_size?: number | null; max_total_item_size?: number | null;
total_item_size?: number; total_item_size?: number;
enabled?: number; enabled?: number;
disabled_time?: number;
} }
export interface UserFlag extends WithDates { export interface UserFlag extends WithDates {
@ -282,6 +277,28 @@ export interface UserDeletion extends WithDates {
error?: string; error?: string;
} }
export interface Email extends WithDates {
id?: number;
recipient_name?: string;
recipient_email?: string;
recipient_id?: Uuid;
sender_id?: EmailSender;
subject?: string;
body?: string;
sent_time?: number;
sent_success?: number;
error?: string;
key?: string;
}
export interface BackupItem extends WithCreatedDate {
id?: number;
type?: number;
key?: string;
user_id?: Uuid;
content?: Buffer;
}
export const databaseSchema: DatabaseTables = { export const databaseSchema: DatabaseTables = {
sessions: { sessions: {
id: { type: 'string' }, id: { type: 'string' },
@ -374,21 +391,6 @@ export const databaseSchema: DatabaseTables = {
previous_item: { type: 'string' }, previous_item: { type: 'string' },
user_id: { type: 'string' }, user_id: { type: 'string' },
}, },
emails: {
id: { type: 'number' },
recipient_name: { type: 'string' },
recipient_email: { type: 'string' },
recipient_id: { type: 'string' },
sender_id: { type: 'number' },
subject: { type: 'string' },
body: { type: 'string' },
sent_time: { type: 'string' },
sent_success: { type: 'number' },
error: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
key: { type: 'string' },
},
tokens: { tokens: {
id: { type: 'number' }, id: { type: 'number' },
value: { type: 'string' }, value: { type: 'string' },
@ -425,6 +427,7 @@ export const databaseSchema: DatabaseTables = {
max_total_item_size: { type: 'string' }, max_total_item_size: { type: 'string' },
total_item_size: { type: 'string' }, total_item_size: { type: 'string' },
enabled: { type: 'number' }, enabled: { type: 'number' },
disabled_time: { type: 'string' },
}, },
user_flags: { user_flags: {
id: { type: 'number' }, id: { type: 'number' },
@ -476,5 +479,28 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' }, updated_time: { type: 'string' },
created_time: { type: 'string' }, created_time: { type: 'string' },
}, },
emails: {
id: { type: 'number' },
recipient_name: { type: 'string' },
recipient_email: { type: 'string' },
recipient_id: { type: 'string' },
sender_id: { type: 'number' },
subject: { type: 'string' },
body: { type: 'string' },
sent_time: { type: 'string' },
sent_success: { type: 'number' },
error: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
key: { type: 'string' },
},
backup_items: {
id: { type: 'number' },
type: { type: 'number' },
key: { type: 'string' },
user_id: { type: 'string' },
content: { type: 'any' },
created_time: { type: 'string' },
},
}; };
// AUTO-GENERATED-TYPES // AUTO-GENERATED-TYPES

View File

@ -36,6 +36,7 @@ const config = {
'main.users': 'WithDates, WithUuid', 'main.users': 'WithDates, WithUuid',
'main.events': 'WithUuid', 'main.events': 'WithUuid',
'main.user_deletions': 'WithDates', 'main.user_deletions': 'WithDates',
'main.backup_items': 'WithCreatedDate',
}, },
}; };
@ -62,6 +63,8 @@ const propertyTypes: Record<string, string> = {
'user_deletions.start_time': 'number', 'user_deletions.start_time': 'number',
'user_deletions.end_time': 'number', 'user_deletions.end_time': 'number',
'user_deletions.scheduled_time': 'number', 'user_deletions.scheduled_time': 'number',
'users.disabled_time': 'number',
'backup_items.content': 'Buffer',
}; };
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void { function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {
@ -92,6 +95,10 @@ function createTypeString(table: any) {
if (['created_time', 'updated_time'].includes(name)) continue; if (['created_time', 'updated_time'].includes(name)) continue;
} }
if (table.extends && table.extends.indexOf('WithCreatedDate') >= 0) {
if (['created_time'].includes(name)) continue;
}
if (table.extends && table.extends.indexOf('WithUuid') >= 0) { if (table.extends && table.extends.indexOf('WithUuid') >= 0) {
if (['id'].includes(name)) continue; if (['id'].includes(name)) continue;
} }

View File

@ -1,5 +1,5 @@
import { Models } from '../models/factory'; import { Models } from '../models/factory';
import TaskService, { Task, TaskId } from '../services/TaskService'; import TaskService, { Task, TaskId, taskIdToLabel } from '../services/TaskService';
import { Services } from '../services/types'; import { Services } from '../services/types';
import { Config, Env } from './types'; import { Config, Env } from './types';
@ -9,28 +9,28 @@ export default function(env: Env, models: Models, config: Config, services: Serv
let tasks: Task[] = [ let tasks: Task[] = [
{ {
id: TaskId.DeleteExpiredTokens, id: TaskId.DeleteExpiredTokens,
description: 'Delete expired tokens', description: taskIdToLabel(TaskId.DeleteExpiredTokens),
schedule: '0 */6 * * *', schedule: '0 */6 * * *',
run: (models: Models) => models.token().deleteExpiredTokens(), run: (models: Models) => models.token().deleteExpiredTokens(),
}, },
{ {
id: TaskId.UpdateTotalSizes, id: TaskId.UpdateTotalSizes,
description: 'Update total sizes', description: taskIdToLabel(TaskId.UpdateTotalSizes),
schedule: '0 * * * *', schedule: '0 * * * *',
run: (models: Models) => models.item().updateTotalSizes(), run: (models: Models) => models.item().updateTotalSizes(),
}, },
{ {
id: TaskId.CompressOldChanges, id: TaskId.CompressOldChanges,
description: 'Compress old changes', description: taskIdToLabel(TaskId.CompressOldChanges),
schedule: '0 0 */2 * *', schedule: '0 0 */2 * *',
run: (models: Models) => models.change().compressOldChanges(), run: (models: Models) => models.change().compressOldChanges(),
}, },
{ {
id: TaskId.ProcessUserDeletions, id: TaskId.ProcessUserDeletions,
description: 'Process user deletions', description: taskIdToLabel(TaskId.ProcessUserDeletions),
schedule: '0 */6 * * *', schedule: '0 */6 * * *',
run: (_models: Models, services: Services) => services.userDeletion.runMaintenance(), run: (_models: Models, services: Services) => services.userDeletion.runMaintenance(),
}, },
@ -41,30 +41,44 @@ export default function(env: Env, models: Models, config: Config, services: Serv
// the UpdateTotalSizes task being run. // the UpdateTotalSizes task being run.
{ {
id: TaskId.HandleOversizedAccounts, id: TaskId.HandleOversizedAccounts,
description: 'Process oversized accounts', description: taskIdToLabel(TaskId.HandleOversizedAccounts),
schedule: '30 */2 * * *', schedule: '30 */2 * * *',
run: (models: Models) => models.user().handleOversizedAccounts(), run: (models: Models) => models.user().handleOversizedAccounts(),
}, },
// This should be enabled eventually. As of version 2.5
// (2021-11-08T11:07:11Z) all Joplin clients support handling of expired
// sessions, however we don't know how many people have Joplin 2.5+ so
// be safe we don't enable it just yet.
// { // {
// id: TaskId.DeleteExpiredSessions, // id: TaskId.DeleteExpiredSessions,
// description: 'Delete expired sessions', // description: taskIdToLabel(TaskId.DeleteExpiredSessions),
// schedule: '0 */6 * * *', // schedule: '0 */6 * * *',
// run: (models: Models) => models.session().deleteExpiredSessions(), // run: (models: Models) => models.session().deleteExpiredSessions(),
// }, // },
]; ];
if (config.USER_DATA_AUTO_DELETE_ENABLED) {
tasks.push({
id: TaskId.AutoAddDisabledAccountsForDeletion,
description: taskIdToLabel(TaskId.AutoAddDisabledAccountsForDeletion),
schedule: '0 14 * * *',
run: (_models: Models, services: Services) => services.userDeletion.autoAddForDeletion(),
});
}
if (config.isJoplinCloud) { if (config.isJoplinCloud) {
tasks = tasks.concat([ tasks = tasks.concat([
{ {
id: TaskId.HandleBetaUserEmails, id: TaskId.HandleBetaUserEmails,
description: 'Process beta user emails', description: taskIdToLabel(TaskId.HandleBetaUserEmails),
schedule: '0 12 * * *', schedule: '0 12 * * *',
run: (models: Models) => models.user().handleBetaUserEmails(), run: (models: Models) => models.user().handleBetaUserEmails(),
}, },
{ {
id: TaskId.HandleFailedPaymentSubscriptions, id: TaskId.HandleFailedPaymentSubscriptions,
description: 'Process failed payment subscriptions', description: taskIdToLabel(TaskId.HandleFailedPaymentSubscriptions),
schedule: '0 13 * * *', schedule: '0 13 * * *',
run: (models: Models) => models.user().handleFailedPaymentSubscriptions(), run: (models: Models) => models.user().handleFailedPaymentSubscriptions(),
}, },

View File

@ -7,7 +7,7 @@ import { Account } from '../models/UserModel';
import { Services } from '../services/types'; import { Services } from '../services/types';
import { Routers } from './routeUtils'; import { Routers } from './routeUtils';
import { DbConnection } from '../db'; import { DbConnection } from '../db';
import { MailerSecurity } from '../env'; import { EnvVariables, MailerSecurity } from '../env';
export enum Env { export enum Env {
Dev = 'dev', Dev = 'dev',
@ -130,7 +130,7 @@ export interface StorageDriverConfig {
bucket?: string; bucket?: string;
} }
export interface Config { export interface Config extends EnvVariables {
appVersion: string; appVersion: string;
appName: string; appName: string;
env: Env; env: Env;

View File

@ -85,3 +85,11 @@ export function adminUserUrl(userId: string) {
export function adminTasksUrl() { export function adminTasksUrl() {
return `${config().adminBaseUrl}/tasks`; return `${config().adminBaseUrl}/tasks`;
} }
export function adminEmailsUrl() {
return `${config().adminBaseUrl}/emails`;
}
export function adminEmailUrl(id: number) {
return `${config().adminBaseUrl}/emails/${id}`;
}

View File

@ -0,0 +1,15 @@
<div class="block">
<strong>Subject: </strong> {{email.subject}}<br/>
<strong>From: </strong> {{sender.name}} &lt;{{sender.email}}&gt; (Sender ID: {{email.sender_id}})<br/>
<strong>To: </strong> {{email.recipient_name}} &lt;{{email.recipient_email}}&gt;{{#email.recipient_id}} (<a href="{{{global.baseUrl}}}/admin/users/{{email.recipient_id}}">User</a>){{/email.recipient_id}}
</div>
<hr/>
<div class="content">
{{{bodyHtml}}}
</div>
<hr/>
<pre class="block">{{email.body}}</pre>

View File

@ -0,0 +1,7 @@
<form method='POST' action="{{postUrl}}">
{{{csrfTag}}}
{{#emailTable}}
{{>table}}
{{/emailTable}}
</form>

View File

@ -26,7 +26,7 @@ We offer a 14 days trial when the subscription starts so that you can evaluate t
## How can I cancel my account? ## How can I cancel my account?
Click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription". We do not cancel accounts over email as we cannot verify your identity. Click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription". Your subscription will be cancelled and you will not be charged on what would have been the next billing period. Please note that we do not cancel accounts over email as we cannot verify your identity, however we can provide assistance if there is an issue.
## Further information ## Further information

View File

@ -19,10 +19,10 @@
<script src="{{{.}}}"></script> <script src="{{{.}}}"></script>
{{/jsFiles}} {{/jsFiles}}
</head> </head>
<body class="page-{{{pageName}}}"> <body class="page-{{{pageName}}} {{#global.isAdminPage}}is-admin-page{{/global.isAdminPage}}">
{{> navbar}} {{> navbar}}
<main class="main"> <main class="main">
<div class="container"> <div class="container main-container">
{{> notifications}} {{> notifications}}
{{#global.isAdminPage}} {{#global.isAdminPage}}

View File

@ -1,23 +1,29 @@
{{#navbar}} {{#navbar}}
<nav class="navbar is-dark" role="navigation" aria-label="main navigation"> <nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="container"> <div class="container navbar-container">
<div class="navbar-brand logo-container"> <div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.baseUrl}}}"> <a class="navbar-item" href="{{{global.baseUrl}}}">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/> <img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
{{^global.owner}}
<span class="navbar-appname">{{global.appName}}</span>
{{/global.owner}}
</a> </a>
</div> </div>
{{#global.owner}} <div class="navbar-menu is-active">
<div class="navbar-menu is-active"> {{#global.owner}}
<div class="navbar-start"> <div class="navbar-start">
{{#global.navbarMenu}} {{#global.navbarMenu}}
<a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i>&nbsp;&nbsp;{{/icon}}{{title}}</a> <a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i>&nbsp;&nbsp;{{/icon}}{{title}}</a>
{{/global.navbarMenu}} {{/global.navbarMenu}}
</div> </div>
<div class="navbar-end"> {{/global.owner}}
{{#global.isJoplinCloud}}
<a class="navbar-item" href="{{{global.baseUrl}}}/help">{{global.s.help}}</a> <div class="navbar-end">
{{/global.isJoplinCloud}} {{#global.isJoplinCloud}}
<a class="navbar-item" href="{{{global.baseUrl}}}/help">{{global.s.help}}</a>
{{/global.isJoplinCloud}}
{{#global.owner}}
<div class="navbar-item"> <div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout"> <form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-dark">{{global.s.logout}}</button> <button class="button is-dark">{{global.s.logout}}</button>
@ -34,17 +40,9 @@
</form> </form>
</div> </div>
{{/global.impersonatorAdminSessionId}} {{/global.impersonatorAdminSessionId}}
</div> {{/global.owner}}
</div> </div>
{{/global.owner}} </div>
{{^global.owner}}
<div class="navbar-menu is-active">
<div class="navbar-start">
<span class="navbar-item">{{global.appName}}</span>
</div>
</div>
{{/global.owner}}
</div> </div>
</nav> </nav>
{{/navbar}} {{/navbar}}

View File

@ -15,6 +15,8 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.3\n" "X-Generator: Poedit 2.4.3\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
#: packages/app-mobile/components/screens/ConfigScreen.tsx:565 #: packages/app-mobile/components/screens/ConfigScreen.tsx:565
msgid "- Camera: to allow taking a picture and attaching it to a note." msgid "- Camera: to allow taking a picture and attaching it to a note."
@ -266,11 +268,11 @@ msgstr "Tilføj til ordbog"
#: packages/server/src/services/MustacheService.ts:182 #: packages/server/src/services/MustacheService.ts:182
#: packages/server/src/services/MustacheService.ts:301 #: packages/server/src/services/MustacheService.ts:301
msgid "Admin" msgid "Admin"
msgstr "" msgstr "Admin"
#: packages/server/src/routes/admin/dashboard.ts:10 #: packages/server/src/routes/admin/dashboard.ts:10
msgid "Admin dashboard" msgid "Admin dashboard"
msgstr "" msgstr "Kontrolpanel til administration"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:189 #: packages/app-desktop/gui/ClipperConfigScreen.min.js:189
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:147 #: packages/app-desktop/gui/ClipperConfigScreen.tsx:147
@ -398,9 +400,8 @@ msgid "Auto-pair braces, parenthesis, quotations, etc."
msgstr "Auto-par klammer, parenteser, citater, etc." msgstr "Auto-par klammer, parenteser, citater, etc."
#: packages/lib/models/Setting.ts:1193 #: packages/lib/models/Setting.ts:1193
#, fuzzy
msgid "Automatically check for updates" msgid "Automatically check for updates"
msgstr "Tjek om der er opdateringer.." msgstr "Tjek automatisk efter opdateringer"
#: packages/lib/models/Setting.ts:770 #: packages/lib/models/Setting.ts:770
msgid "Automatically switch theme to match system theme" msgid "Automatically switch theme to match system theme"
@ -764,9 +765,8 @@ msgid "Copy external link"
msgstr "Kopiér eksternt link" msgstr "Kopiér eksternt link"
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:134 #: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:134
#, fuzzy
msgid "Copy image" msgid "Copy image"
msgstr "Kopier token" msgstr "Kopier billede"
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:172 #: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:172
msgid "Copy Link Address" msgid "Copy Link Address"
@ -860,14 +860,12 @@ msgid "Create a notebook"
msgstr "Opret en notesbog" msgstr "Opret en notesbog"
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:128 #: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:128
#, fuzzy
msgid "Create notebook" msgid "Create notebook"
msgstr "Opret en notesbog" msgstr "Opret notesbog"
#: packages/server/src/routes/admin/users.ts:171 #: packages/server/src/routes/admin/users.ts:171
#, fuzzy
msgid "Create user" msgid "Create user"
msgstr "Oprettet: %s" msgstr "Opret bruger"
#: packages/app-desktop/gui/NotePropertiesDialog.min.js:29 #: packages/app-desktop/gui/NotePropertiesDialog.min.js:29
msgid "Created" msgid "Created"
@ -959,7 +957,7 @@ msgstr "Mørkt"
#: packages/server/src/services/MustacheService.ts:139 #: packages/server/src/services/MustacheService.ts:139
msgid "Dashboard" msgid "Dashboard"
msgstr "" msgstr "Kontrolpanel"
#: packages/app-mobile/components/screens/ConfigScreen.tsx:625 #: packages/app-mobile/components/screens/ConfigScreen.tsx:625
msgid "Database v%s" msgid "Database v%s"
@ -2214,7 +2212,7 @@ msgstr "Log ud"
#: packages/server/src/services/MustacheService.ts:178 #: packages/server/src/services/MustacheService.ts:178
msgid "Logs" msgid "Logs"
msgstr "" msgstr "Logfiler"
#: packages/app-desktop/gui/MenuBar.tsx:710 #: packages/app-desktop/gui/MenuBar.tsx:710
#: packages/app-mobile/components/screens/ConfigScreen.tsx:583 #: packages/app-mobile/components/screens/ConfigScreen.tsx:583
@ -2873,7 +2871,7 @@ msgstr "Privatlivspolitik"
#: packages/server/src/routes/admin/users.ts:168 #: packages/server/src/routes/admin/users.ts:168
msgid "Profile" msgid "Profile"
msgstr "" msgstr "Profil"
#: packages/lib/versionInfo.ts:26 #: packages/lib/versionInfo.ts:26
msgid "Profile Version: %s" msgid "Profile Version: %s"
@ -4157,9 +4155,8 @@ msgstr "Opdater"
#: packages/server/src/routes/admin/users.ts:171 #: packages/server/src/routes/admin/users.ts:171
#: packages/server/src/routes/index/users.ts:89 #: packages/server/src/routes/index/users.ts:89
#, fuzzy
msgid "Update profile" msgid "Update profile"
msgstr "Eksporter profil" msgstr "Opdater profil"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:208 #: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:208
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:209 #: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:209
@ -4267,7 +4264,7 @@ msgstr ""
#: packages/server/src/services/MustacheService.ts:147 #: packages/server/src/services/MustacheService.ts:147
msgid "User deletions" msgid "User deletions"
msgstr "" msgstr "Brugersletninger"
#: packages/server/src/routes/admin/users.ts:107 #: packages/server/src/routes/admin/users.ts:107
#: packages/server/src/services/MustacheService.ts:143 #: packages/server/src/services/MustacheService.ts:143

View File

@ -1981,7 +1981,7 @@ msgstr "Zainstaluj z pliku"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:193 #: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:193
msgid "Installed" msgid "Installed"
msgstr "Zainstwalony" msgstr "Zainstalowany"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:192 #: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:192
msgid "Installing..." msgid "Installing..."

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