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:
commit
980190ec09
2
.github/workflows/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@ -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
|
||||||
|
@ -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 = [];
|
|
||||||
}
|
|
||||||
|
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*****************************************************************
|
/*****************************************************************
|
||||||
|
5
BUILD.md
5
BUILD.md
@ -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
|
||||||
|
@ -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
|
||||||
|
5
LICENSE
5
LICENSE
@ -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,
|
||||||
|
21
README.md
21
README.md
@ -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.
|
||||||
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiNhYmIyYmY7YmFja2dyb3VuZDojMjgyYzM0fS5obGpzLWtleXdvcmQsLmhsanMtb3BlcmF0b3IsLmhsanMtcGF0dGVybi1tYXRjaHtjb2xvcjojZjkyNjcyfS5obGpzLWZ1bmN0aW9uLC5obGpzLXBhdHRlcm4tbWF0Y2ggLmhsanMtY29uc3RydWN0b3J7Y29sb3I6IzYxYWVlZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXN7Y29sb3I6I2E2ZTIyZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXMgLmhsanMtdHlwaW5ne2NvbG9yOiNmZDk3MWZ9LmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGV7Y29sb3I6IzdlNTdjMn0uaGxqcy1jb25zdHJ1Y3Rvcntjb2xvcjojZTJiOTNkfS5obGpzLWNvbnN0cnVjdG9yIC5obGpzLXN0cmluZ3tjb2xvcjojOWNjYzY1fS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2IxOGViMTtmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYXtjb2xvcjojYzY3OGRkfS5obGpzLWRlbGV0aW9uLC5obGpzLW5hbWUsLmhsanMtc2VjdGlvbiwuaGxqcy1zZWxlY3Rvci10YWcsLmhsanMtc3Vic3R7Y29sb3I6I2UwNmM3NX0uaGxqcy1saXRlcmFse2NvbG9yOiM1NmI2YzJ9LmhsanMtYWRkaXRpb24sLmhsanMtYXR0cmlidXRlLC5obGpzLW1ldGEgLmhsanMtc3RyaW5nLC5obGpzLXJlZ2V4cCwuaGxqcy1zdHJpbmd7Y29sb3I6Izk4YzM3OX0uaGxqcy1idWlsdF9pbiwuaGxqcy1jbGFzcyAuaGxqcy10aXRsZSwuaGxqcy10aXRsZS5jbGFzc197Y29sb3I6I2U2YzA3Yn0uaGxqcy1hdHRyLC5obGpzLW51bWJlciwuaGxqcy1zZWxlY3Rvci1hdHRyLC5obGpzLXNlbGVjdG9yLWNsYXNzLC5obGpzLXNlbGVjdG9yLXBzZXVkbywuaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwuaGxqcy10eXBlLC5obGpzLXZhcmlhYmxle2NvbG9yOiNkMTlhNjZ9LmhsanMtYnVsbGV0LC5obGpzLWxpbmssLmhsanMtbWV0YSwuaGxqcy1zZWxlY3Rvci1pZCwuaGxqcy1zeW1ib2wsLmhsanMtdGl0bGV7Y29sb3I6IzYxYWVlZX0uaGxqcy1lbXBoYXNpc3tmb250LXN0eWxlOml0YWxpY30uaGxqcy1zdHJvbmd7Zm9udC13ZWlnaHQ6NzAwfS5obGpzLWxpbmt7dGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZX0=`;
|
module.exports = `LyoKCkF0b20gT25lIERhcmsgV2l0aCBzdXBwb3J0IGZvciBSZWFzb25NTCBieSBHaWRpIE1vcnJpcywgYmFzZWQgb2ZmIHdvcmsgYnkgRGFuaWVsIEdhbWFnZQoKT3JpZ2luYWwgT25lIERhcmsgU3ludGF4IHRoZW1lIGZyb20gaHR0cHM6Ly9naXRodWIuY29tL2F0b20vb25lLWRhcmstc3ludGF4CgoqLwouaGxqcyB7CiAgY29sb3I6ICNhYmIyYmY7CiAgYmFja2dyb3VuZDogIzI4MmMzNDsKfQouaGxqcy1rZXl3b3JkLCAuaGxqcy1vcGVyYXRvciB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCAuaGxqcy1jb25zdHJ1Y3RvciB7CiAgY29sb3I6ICM2MWFlZWU7Cn0KLmhsanMtZnVuY3Rpb24gewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWZ1bmN0aW9uIC5obGpzLXBhcmFtcyB7CiAgY29sb3I6ICNBNkUyMkU7Cn0KLmhsanMtZnVuY3Rpb24gLmhsanMtcGFyYW1zIC5obGpzLXR5cGluZyB7CiAgY29sb3I6ICNGRDk3MUY7Cn0KLmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGUgewogIGNvbG9yOiAjN2U1N2MyOwp9Ci5obGpzLWNvbnN0cnVjdG9yIHsKICBjb2xvcjogI2UyYjkzZDsKfQouaGxqcy1jb25zdHJ1Y3RvciAuaGxqcy1zdHJpbmcgewogIGNvbG9yOiAjOUNDQzY1Owp9Ci5obGpzLWNvbW1lbnQsIC5obGpzLXF1b3RlIHsKICBjb2xvcjogI2IxOGViMTsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtZG9jdGFnLCAuaGxqcy1mb3JtdWxhIHsKICBjb2xvcjogI2M2NzhkZDsKfQouaGxqcy1zZWN0aW9uLCAuaGxqcy1uYW1lLCAuaGxqcy1zZWxlY3Rvci10YWcsIC5obGpzLWRlbGV0aW9uLCAuaGxqcy1zdWJzdCB7CiAgY29sb3I6ICNlMDZjNzU7Cn0KLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICM1NmI2YzI7Cn0KLmhsanMtc3RyaW5nLCAuaGxqcy1yZWdleHAsIC5obGpzLWFkZGl0aW9uLCAuaGxqcy1hdHRyaWJ1dGUsIC5obGpzLW1ldGEgLmhsanMtc3RyaW5nIHsKICBjb2xvcjogIzk4YzM3OTsKfQouaGxqcy1idWlsdF9pbiwKLmhsanMtdGl0bGUuY2xhc3NfLAouaGxqcy1jbGFzcyAuaGxqcy10aXRsZSB7CiAgY29sb3I6ICNlNmMwN2I7Cn0KLmhsanMtYXR0ciwgLmhsanMtdmFyaWFibGUsIC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLCAuaGxqcy10eXBlLCAuaGxqcy1zZWxlY3Rvci1jbGFzcywgLmhsanMtc2VsZWN0b3ItYXR0ciwgLmhsanMtc2VsZWN0b3ItcHNldWRvLCAuaGxqcy1udW1iZXIgewogIGNvbG9yOiAjZDE5YTY2Owp9Ci5obGpzLXN5bWJvbCwgLmhsanMtYnVsbGV0LCAuaGxqcy1saW5rLCAuaGxqcy1tZXRhLCAuaGxqcy1zZWxlY3Rvci1pZCwgLmhsanMtdGl0bGUgewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWVtcGhhc2lzIHsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQouaGxqcy1saW5rIHsKICB0ZXh0LWRlY29yYXRpb246IHVuZGVybGluZTsKfQo=`;
|
@ -1 +1 @@
|
|||||||
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiMzODNhNDI7YmFja2dyb3VuZDojZmFmYWZhfS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2EwYTFhNztmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYSwuaGxqcy1rZXl3b3Jke2NvbG9yOiNhNjI2YTR9LmhsanMtZGVsZXRpb24sLmhsanMtbmFtZSwuaGxqcy1zZWN0aW9uLC5obGpzLXNlbGVjdG9yLXRhZywuaGxqcy1zdWJzdHtjb2xvcjojZTQ1NjQ5fS5obGpzLWxpdGVyYWx7Y29sb3I6IzAxODRiYn0uaGxqcy1hZGRpdGlvbiwuaGxqcy1hdHRyaWJ1dGUsLmhsanMtbWV0YSAuaGxqcy1zdHJpbmcsLmhsanMtcmVnZXhwLC5obGpzLXN0cmluZ3tjb2xvcjojNTBhMTRmfS5obGpzLWF0dHIsLmhsanMtbnVtYmVyLC5obGpzLXNlbGVjdG9yLWF0dHIsLmhsanMtc2VsZWN0b3ItY2xhc3MsLmhsanMtc2VsZWN0b3ItcHNldWRvLC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLC5obGpzLXR5cGUsLmhsanMtdmFyaWFibGV7Y29sb3I6Izk4NjgwMX0uaGxqcy1idWxsZXQsLmhsanMtbGluaywuaGxqcy1tZXRhLC5obGpzLXNlbGVjdG9yLWlkLC5obGpzLXN5bWJvbCwuaGxqcy10aXRsZXtjb2xvcjojNDA3OGYyfS5obGpzLWJ1aWx0X2luLC5obGpzLWNsYXNzIC5obGpzLXRpdGxlLC5obGpzLXRpdGxlLmNsYXNzX3tjb2xvcjojYzE4NDAxfS5obGpzLWVtcGhhc2lze2ZvbnQtc3R5bGU6aXRhbGljfS5obGpzLXN0cm9uZ3tmb250LXdlaWdodDo3MDB9LmhsanMtbGlua3t0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lfQ==`;
|
module.exports = `LyoKCkF0b20gT25lIExpZ2h0IGJ5IERhbmllbCBHYW1hZ2UKT3JpZ2luYWwgT25lIExpZ2h0IFN5bnRheCB0aGVtZSBmcm9tIGh0dHBzOi8vZ2l0aHViLmNvbS9hdG9tL29uZS1saWdodC1zeW50YXgKCmJhc2U6ICAgICNmYWZhZmEKbW9uby0xOiAgIzM4M2E0Mgptb25vLTI6ICAjNjg2Yjc3Cm1vbm8tMzogICNhMGExYTcKaHVlLTE6ICAgIzAxODRiYgpodWUtMjogICAjNDA3OGYyCmh1ZS0zOiAgICNhNjI2YTQKaHVlLTQ6ICAgIzUwYTE0ZgpodWUtNTogICAjZTQ1NjQ5Cmh1ZS01LTI6ICNjOTEyNDMKaHVlLTY6ICAgIzk4NjgwMQpodWUtNi0yOiAjYzE4NDAxCgoqLwoKLmhsanMgewogIGNvbG9yOiAjMzgzYTQyOwogIGJhY2tncm91bmQ6ICNmYWZhZmE7Cn0KCi5obGpzLWNvbW1lbnQsCi5obGpzLXF1b3RlIHsKICBjb2xvcjogI2EwYTFhNzsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KCi5obGpzLWRvY3RhZywKLmhsanMta2V5d29yZCwKLmhsanMtZm9ybXVsYSB7CiAgY29sb3I6ICNhNjI2YTQ7Cn0KCi5obGpzLXNlY3Rpb24sCi5obGpzLW5hbWUsCi5obGpzLXNlbGVjdG9yLXRhZywKLmhsanMtZGVsZXRpb24sCi5obGpzLXN1YnN0IHsKICBjb2xvcjogI2U0NTY0OTsKfQoKLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICMwMTg0YmI7Cn0KCi5obGpzLXN0cmluZywKLmhsanMtcmVnZXhwLAouaGxqcy1hZGRpdGlvbiwKLmhsanMtYXR0cmlidXRlLAouaGxqcy1tZXRhIC5obGpzLXN0cmluZyB7CiAgY29sb3I6ICM1MGExNGY7Cn0KCi5obGpzLWF0dHIsCi5obGpzLXZhcmlhYmxlLAouaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwKLmhsanMtdHlwZSwKLmhsanMtc2VsZWN0b3ItY2xhc3MsCi5obGpzLXNlbGVjdG9yLWF0dHIsCi5obGpzLXNlbGVjdG9yLXBzZXVkbywKLmhsanMtbnVtYmVyIHsKICBjb2xvcjogIzk4NjgwMTsKfQoKLmhsanMtc3ltYm9sLAouaGxqcy1idWxsZXQsCi5obGpzLWxpbmssCi5obGpzLW1ldGEsCi5obGpzLXNlbGVjdG9yLWlkLAouaGxqcy10aXRsZSB7CiAgY29sb3I6ICM0MDc4ZjI7Cn0KCi5obGpzLWJ1aWx0X2luLAouaGxqcy10aXRsZS5jbGFzc18sCi5obGpzLWNsYXNzIC5obGpzLXRpdGxlIHsKICBjb2xvcjogI2MxODQwMTsKfQoKLmhsanMtZW1waGFzaXMgewogIGZvbnQtc3R5bGU6IGl0YWxpYzsKfQoKLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQoKLmhsanMtbGluayB7CiAgdGV4dC1kZWNvcmF0aW9uOiB1bmRlcmxpbmU7Cn0K`;
|
@ -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
@ -1 +1 @@
|
|||||||
module.exports = `d09GMgABAAAAAA1oAA4AAAAAG5wAAA0RAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmAAgRQIDAmXFxEICo8Ai1YBNgIkA14LMgAEIAWIYgeBHAx/G8kYIxFmk1QA+IsE3hD16QuQENMSYXJ4+9F5hfA6Tq6zy/sjJJn14Wlbvf8nGGYoJbd0rMKI2DAKBF2jCrfwrnG3jYsIvchWL7oh+Hb9vn4zTcBxyIHEsWbncP0PMA4b/h9t/awowdPc8MA3g9iXwbIG/p0EzeyXoqaop6tOBrfMnHeEmFZ7hJ2mYvTqERU4fVRvN6X/b660///kaK5IiskT2ApZYyazu/cymaXsAVG2hKQAFTkiUO3J+roTuq++ylTIqg5Zxn8mCgpd1yXakKZViWI5+LSbPwAo/ZkcBwdQD30AwOHy8cmHiA+Kx4Bu4Acdvv0Or1J1ijuRR15fMbJ3TUO9gblLBo41M0iSZHOLaJcGpMixIQv3n72dDgMVZBFiNdnngMvoU/RdhgncGiSFfeX1BsjCxdpLgbSBEPbl74V716abHHPU4H87/0v4T/3eH++Jb1pBkPyk7RFEC9jTNEtv/kHzeAP//JRIWhP+Kifq7Qmq3X/BNVx2aAvnPagHhTYZQBxLDBAgxycBCh2JR7Zgw6ch3IucHk53Pidv3vVOUbWVPHnZibRaRdorqxLmdkeE054WrqE4K44aHnHoRL2nC0GUhbXkfkusUQqk1ICSc3YQy9lAVTrOOcBAU8FqdyQ4biU4ifASnfcZEgdYmDevo8qlUTe3AaKUAlpHLszN594J1cv/q8tW46ohsGE7lS3X7eRk2Yu83QQxNzCPdn7PyAEFbw2g8AD/zhQSTSAodtD0BJJBYrhbQAFpKiQe/mNdKil55JVMFl8mhl5OwKBoO07jTQ84KOd2gFeUdHNiBgphtWjaByU6QiuEbYhWI6/Jw4j3ArVuomZFJBE2wZWI+e5AJQb1jgapVY6Sd+bzTR9Qo9JSRGc3CWgAiSVUfL01gjKJGXUL1qB6/IAWQgfLFfWPdVkflKBhUd8qzEVgd6LDm71U2XySL6Mu7EfAhy3YTm92z7bzQ9N37osKLfdNLbo2JIloLYNCqMaq2qF6SsjMBKNbUgHg+LAae49UT3LzBknqXkcD1DhdN2dGxTlqMzLzcNeCOPG6FThlEwhBzkC8v9YAPZYqaLSX0ErUFEkiooDWqAJJfusGy+i5e+xbq28tiqlb7lIOGFChvxUwopIymIiCmRiwEAtbiIOtxMM2UsB2EmAHKZ0RUAP5erRI+NWi4O+E9sMSarMT/dEPZbGBAKIQSAwEEQvBxEEI8RBKCggjAcJJaUYbtBBDTHhBF2tslBq7hOMQ0TFXa6wgxoi5BWIhzeWNPOvWbCpbcKrJnM+BOBedn8yIvVXOT6fpTh5kIB6d+ZPkJ0APbZaJ2vWQSFE6xAyYb18eTL+Q1J7JU1urCd54M5AEtZrbnAIK/RcKJKM51wZSyHMhPCyZMhWVutkVNT0NBEX3e3rVe+Y2t05p4XyGSBxSPJmevhlIAzDBKqySnRklbx33VpHaPk0yUHqCGjl5sBIp00TrMj9l3nb6SQ+rskPXCatbc4wmBKoP4kixWfFtEWqxmIvcE/4ZKMuRwbA7coOwO3dbZR6aJ99ddSbBBpt1a84QOIMqdGlab9HOsrW0rCw4F067c56EabBhVEoNidbinunm0JeXAxk49CAJLqRYu5bSMntDkChpJhQKj1lZfa5BeFC9HGypUWveQTu2EBzsNq/MUq9egd3Omm8Ue7xjSSj7sq3w0z1WeYme5zDj3FmWA1OD3BlnO0ltbqrVG26lonIH2VpGHsf86Q6dPyLNBDsLRqRbL7MiYNbXp23DYNlglQcKo4yCJwxzTK3ctyYyp2hUhfeqjuH4T8wjUAxBzDH7t7bmvM8TfbbTKgYkDgMqSfS2X4etDRt3eo0LaGjAsmieykUn3YZmV8nuUQUhbJLv3D0jUiGUXgqmqanRrA9KsAqVjULSpjQMKx8NeKCoYwURqGQEWEUEqhsCxVhDBGoZAdqIgL0hkI91RMDBCNBJBOobAoXYQAT2MgJsJAJNDYESbCYCLYwAW4lAW0OgANuJQAcjwE4ixV3RqxlQW90N9mrOS+vBXOgNwbTGPOgLZHJAv54JA3qmGtxgHgwFsh4wrGfBiJ4Fo3oWjEVlw7ieDRN6Nkzq2ZNMRd+uZOkbKLNgTip0rTLBVbOvK+eIAQHxetMiYRY3iAMYgfwM0M+QRG/XZg7Fssh+jAA5I8v6bzcJFqFqlyF0t/349n2ZLRInsXUSczDR5TaIphtqPm/eIPOOD3pejzJLS3Ab3niXnmZ/y77UcXb3/IokGa+v/qxpHW7bu52vhBtEzzJ52PGFy3MBmTev/OPfWlq43nCe840/bX8aFl371BBdElzuRdFjWp7b+LvubTK/Ii0Zl1zrf9nf2kfULhWR9rkMomljo+6CKxreLpvLci4804r55dpNx8red67cBh1myQi3YT/TPeFv2M67zPnmZZY3lrm4XclgCFlft59/eb0b61uWsMKvT1/CEv75r8bj7jbrcbj93pClJbhrgs9k1foMMlz2BwnRm+Y9d9Nx4QfjXDdm3p8ZFrpRd8H7W035mzUvfuJwtH0kuS0Wz/eip/VDcesFLX7zm2GbjqelBeO6fb34TmnBca20w217w7boMW3UbZTcJXpsV81xD/vc9V3Lf5vm4xavMxiWTSaHwzWPq2PJ6P7TsKgRt5DKhV1Q3AfzJJcau/FUxEFaPsD/6kDju+6mJriv85iWm10eknTON79P5s0XKmzj0oevJzcsJhiXjR3xCWsJQtTrNzF2/8TNw8k2ayZfOT3JV1sz4hc9vJnob2fYGx979oH8hexc796BT769kb/x7y3nnitMaLQ8/Wbw1ZNTfJU184fQajuE7zPK/A/5JM/p75Q+lFgSnvJViG+pTkXE6qMH/ar1v9gafvRNlBL7ee3EnVC2qHNehvDJyzl89uMXSF5R25+o4DJDywtOeWpBUP2netTZPNviUR53Duaoud8sH3rlrXa/9ZYw136fPUUidPK7VN7mLgq+i6EGc8LEh2JykapGiXy6sb7ieU/Yfveza3U+9FT8qeZd+qZEBXS0UpGmqHOmIiLgzg7daYItYmi/dmeRBE1cKV6Io1Tf1Lyr5otQHx0vJJ/lV6ziNJ81hc3O+eyuU0GL3Kmb6rGXznWEqEU+seCiSwp2aRp3II9WVSfjUi5/Q59pdLWiglcmt8fplqotCyATWlVFYt5g+bE/09Lva916Bcg4cjz/hOt6nmk42We7N/LeqP7afhshCQnc42eyTGdBewETt+DFtuNvyp89K3JME5aeuVqmOBhcv2XlqdQrf6ssYY5zk4HXJx2VVhtWy3h3yDf27I198Q2n7RU8MvvELq8/2b5/c1tPv0OnvoOC+aVfSi6x9bxQ6iqbVeufYfyf6TXUq/h7eSHw2YzEcGMY0nZ4fLziHAWUAW7EYplDiF7PLUyRw/sEzXSVQ35H/IGffL7e/dVu/S06NTTCF4Vf5PPHFF8UKDU6y/f6XL+84vU7/fyUXxRM/WWhmpIzDT63OK/k44n2fF2Kg41yAFL/UuZd7gbvvzSJu5Zh/3biaDBnuJNnCWhc1O0+ZkxmYJkSGmEayhiiBbBQykR2nQrU2Ta9RkbLEObLxajAJwMe6aLno79u0Wb9JkjMlwDw7ti/15dv+Lfz3z3ctdwNAARQgItzPJODhnqVItr77TZAkJSb/Y0E8tRyIC4F/RCvnlMHFgNSkw5/YEwrlej3OM2s0+lyuKu8/1oXIMDfzj6bmC8xknbi94BULv8eBzXrbGtRhbpuDXVmGm4dKyK5mRLjGBBWCeAKoN6BwOSKDhQa3NOBQYmnOrAIDVUHDsGRsCTf10FNu0nd+9QgKT+AQiNGuYzr06PXJFm4DhFkCeLESRdzk/FtfFmRLhP0crhjLW/Y1W+HLtFku3fOSpTePF4SpslSnWqTjPmpbTq9dIhLaYy2Pp1a1OozV5qoRldLnTKoKB1XYqRaTZbleE516VFWq3ckgyxnGTHHRUEp65Yax4FGDDt/inRpkiRLldqyCJFdVdFHsU3PMpOWWg1pa6m9+rGc5dwI/Cc5InwwL62+Acql2g5za5fRztWqzzYl46cmUJt8WpdOsr+6lFVoM7RAPhKRJMWc7GWXnuCWtRF2y5mJ4z6ZSg5i1YEdTmAnTqlOG5RsleqSkLpbWUuvNEo/l/Sly6RA9Lx9nh7Pmrusm7/uUlmbaVN9UdOw3eCSMi6qTWt6vup6+aQMoo/4JUebjN3TJ3T4To52HiurBYFBpE/8WJVKlPHFUyRj9Z5eAr/la3rvHRJU8JUjV55mrUHxJt4iDGFxNeEITxREIEoiKqaG++Li8uPKp0+MS1BO9g12dv0vYaIVEnxp64j0iHytoGlqcdqN0lH64Zka5xohR+tXycftM9418AVrQOR6UOGAewEoj2dmUPSCyTnVztW8c5wMdFKo4z7BDcLSaR02J5nc29nXoIq8h3jPHHtkjVamcZ1FAAAA`;
|
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
@ -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;
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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;
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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.
@ -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'),
|
||||||
|
@ -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;
|
||||||
|
@ -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 => {
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
@ -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');
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
29
packages/server/src/models/BackupItemModel.ts
Normal file
29
packages/server/src/models/BackupItemModel.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
31
packages/server/src/models/utils/email.ts
Normal file
31
packages/server/src/models/utils/email.ts
Normal 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];
|
||||||
|
};
|
136
packages/server/src/routes/admin/emails.ts
Normal file
136
packages/server/src/routes/admin/emails.ts
Normal 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;
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
|
@ -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';
|
||||||
|
@ -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() {
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
@ -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;
|
||||||
|
@ -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}`;
|
||||||
|
}
|
||||||
|
15
packages/server/src/views/admin/email.mustache
Normal file
15
packages/server/src/views/admin/email.mustache
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<div class="block">
|
||||||
|
<strong>Subject: </strong> {{email.subject}}<br/>
|
||||||
|
<strong>From: </strong> {{sender.name}} <{{sender.email}}> (Sender ID: {{email.sender_id}})<br/>
|
||||||
|
<strong>To: </strong> {{email.recipient_name}} <{{email.recipient_email}}>{{#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>
|
7
packages/server/src/views/admin/emails.mustache
Normal file
7
packages/server/src/views/admin/emails.mustache
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<form method='POST' action="{{postUrl}}">
|
||||||
|
{{{csrfTag}}}
|
||||||
|
|
||||||
|
{{#emailTable}}
|
||||||
|
{{>table}}
|
||||||
|
{{/emailTable}}
|
||||||
|
</form>
|
@ -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
|
||||||
|
|
||||||
|
@ -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}}
|
||||||
|
@ -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> {{/icon}}{{title}}</a>
|
<a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i> {{/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}}
|
@ -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
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user