1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-23 23:33:01 +02:00

Compare commits

..

8 Commits

Author SHA1 Message Date
Laurent Cozic
84a793270d move back to test 2021-05-21 15:15:48 +02:00
Laurent Cozic
5f8b73350d other tdsts 2021-05-21 15:09:08 +02:00
Laurent Cozic
a733cb0394 services 2021-05-21 13:44:57 +02:00
Laurent Cozic
7da4cb0d80 sync tests 2021-05-21 13:22:50 +02:00
Laurent Cozic
3233082b4c move more 2021-05-21 13:14:45 +02:00
Laurent Cozic
c782f5e981 move 2021-05-21 13:01:43 +02:00
Laurent Cozic
db51e2dd4b cli setup 2021-05-21 12:42:19 +02:00
Laurent Cozic
f00f3d7f04 test move 2021-05-21 12:33:33 +02:00
58 changed files with 368 additions and 1015 deletions

View File

@@ -83,9 +83,6 @@ packages/app-cli/tests/MdToHtml.js.map
packages/app-cli/tests/MdToMd.d.ts
packages/app-cli/tests/MdToMd.js
packages/app-cli/tests/MdToMd.js.map
packages/app-cli/tests/services/keychain/KeychainService.d.ts
packages/app-cli/tests/services/keychain/KeychainService.js
packages/app-cli/tests/services/keychain/KeychainService.js.map
packages/app-cli/tests/services/plugins/PluginService.d.ts
packages/app-cli/tests/services/plugins/PluginService.js
packages/app-cli/tests/services/plugins/PluginService.js.map
@@ -1169,6 +1166,9 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js.map
packages/lib/services/keychain/KeychainServiceDriverBase.d.ts
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/keychain/KeychainServiceDriverBase.js.map
packages/lib/services/keychain/keychainService.test.d.ts
packages/lib/services/keychain/keychainService.test.js
packages/lib/services/keychain/keychainService.test.js.map
packages/lib/services/plugins/BasePluginRunner.d.ts
packages/lib/services/plugins/BasePluginRunner.js
packages/lib/services/plugins/BasePluginRunner.js.map

6
.gitignore vendored
View File

@@ -69,9 +69,6 @@ packages/app-cli/tests/MdToHtml.js.map
packages/app-cli/tests/MdToMd.d.ts
packages/app-cli/tests/MdToMd.js
packages/app-cli/tests/MdToMd.js.map
packages/app-cli/tests/services/keychain/KeychainService.d.ts
packages/app-cli/tests/services/keychain/KeychainService.js
packages/app-cli/tests/services/keychain/KeychainService.js.map
packages/app-cli/tests/services/plugins/PluginService.d.ts
packages/app-cli/tests/services/plugins/PluginService.js
packages/app-cli/tests/services/plugins/PluginService.js.map
@@ -1155,6 +1152,9 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js.map
packages/lib/services/keychain/KeychainServiceDriverBase.d.ts
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/keychain/KeychainServiceDriverBase.js.map
packages/lib/services/keychain/keychainService.test.d.ts
packages/lib/services/keychain/keychainService.test.js
packages/lib/services/keychain/keychainService.test.js.map
packages/lib/services/plugins/BasePluginRunner.d.ts
packages/lib/services/plugins/BasePluginRunner.js
packages/lib/services/plugins/BasePluginRunner.js.map

View File

@@ -31,7 +31,7 @@ if [ "$RESET_ALL" == "1" ]; then
echo "config sync.9.password 123456" >> "$CMD_FILE"
if [ "$1" == "1" ]; then
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://localhost:22300/api/debug
curl --data '{"action": "createTestUsers"}' http://localhost:22300/api/debug
echo 'mkbook "shared"' >> "$CMD_FILE"
echo 'mkbook "other"' >> "$CMD_FILE"

View File

@@ -1,8 +1,17 @@
const { afterEachCleanUp } = require('./testing/test-utils.js');
const { shimInit } = require('./shim-init-node.js');
const shim = require('./shim').default;
const sharp = require('sharp');
shimInit(sharp, null);
let keytar;
try {
keytar = shim.platformSupportsKeyChain() ? require('keytar') : null;
} catch (error) {
console.error('Cannot load keytar - keychain support will be disabled', error);
keytar = null;
}
shimInit(sharp, keytar);
global.afterEach(async () => {
await afterEachCleanUp();

View File

@@ -4059,6 +4059,24 @@
"verror": "1.10.0"
}
},
"keytar": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.7.0.tgz",
"integrity": "sha512-YEY9HWqThQc5q5xbXbRwsZTh2PJ36OSYRjSv3NN2xf5s5dpLTjEZnC2YikR29OaVybf9nQ0dJ/80i40RS97t/A==",
"dev": true,
"requires": {
"node-addon-api": "^3.0.0",
"prebuild-install": "^6.0.0"
},
"dependencies": {
"node-addon-api": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.0.tgz",
"integrity": "sha512-kcwSAWhPi4+QzAtsL2+2s/awvDo2GKLsvMCwNRxb5BUshteXU8U97NCyvQDsGKs/m0He9WcG4YWew/BnuLx++w==",
"dev": true
}
}
},
"kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",

View File

@@ -21,6 +21,7 @@
"@types/fs-extra": "^9.0.6",
"jest": "^26.6.3",
"sharp": "^0.26.2",
"keytar": "^7.0.0",
"typescript": "^4.0.5",
"clean-html": "^1.5.0"
},

View File

@@ -1,7 +1,8 @@
import KeychainService from '@joplin/lib/services/keychain/KeychainService';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import { db, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import KeychainService from '../../services/keychain/KeychainService';
import shim from '../../shim';
import Setting from '../../models/Setting';
const { db, setupDatabaseAndSynchronizer, switchClient } = require('../../testing/test-utils.js');
function describeIfCompatible(name: string, fn: any, elseFn: any) {
if (['win32', 'darwin'].includes(shim.platformName())) {

View File

@@ -48,8 +48,10 @@ export interface Command {
* Currently the supported context variables aren't documented, but you can
* find the list below:
*
* - [Global When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts)
* - [Desktop app When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts)
* - [Global When
* Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts).
* - [Desktop app When
* Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts).
*
* Note: Commands are enabled by default unless you use this property.
*/

View File

@@ -0,0 +1,49 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.readCredentialFile = exports.credentialFile = exports.credentialDir = void 0;
const fs = require('fs-extra');
function credentialDir() {
return __awaiter(this, void 0, void 0, function* () {
const username = require('os').userInfo().username;
const toTry = [
`c:/Users/${username}/joplin-credentials`,
`/mnt/c/Users/${username}/joplin-credentials`,
`/home/${username}/joplin-credentials`,
`/Users/${username}/joplin-credentials`,
];
for (const dirPath of toTry) {
if (yield fs.pathExists(dirPath))
return dirPath;
}
throw new Error(`Could not find credential directory in any of these paths: ${JSON.stringify(toTry)}`);
});
}
exports.credentialDir = credentialDir;
function credentialFile(filename) {
return __awaiter(this, void 0, void 0, function* () {
const rootDir = yield credentialDir();
const output = `${rootDir}/${filename}`;
if (!(yield fs.pathExists(output)))
throw new Error(`No such file: ${output}`);
return output;
});
}
exports.credentialFile = credentialFile;
function readCredentialFile(filename) {
return __awaiter(this, void 0, void 0, function* () {
const filePath = yield credentialFile(filename);
const r = yield fs.readFile(filePath);
return r.toString();
});
}
exports.readCredentialFile = readCredentialFile;
//# sourceMappingURL=credentialFiles.js.map

View File

@@ -24,12 +24,8 @@ export async function credentialFile(filename: string) {
return output;
}
export async function readCredentialFile(filename: string, defaultValue: string = '') {
try {
const filePath = await credentialFile(filename);
const r = await fs.readFile(filePath);
return r.toString();
} catch (error) {
return defaultValue;
}
export async function readCredentialFile(filename: string) {
const filePath = await credentialFile(filename);
const r = await fs.readFile(filePath);
return r.toString();
}

View File

@@ -6,5 +6,4 @@ db-*.sqlite
*.pid
logs/
tests/temp/
temp/
.env
temp/

View File

@@ -1,9 +1,4 @@
{
"verbose": true,
"watch": [
"dist/",
"../renderer",
"../lib",
"src/views"
]
"watch": ["dist/", "../renderer", "../lib"]
}

View File

@@ -1389,15 +1389,6 @@
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==",
"dev": true
},
"@types/nodemailer": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.1.tgz",
"integrity": "sha512-8081UY/0XTTDpuGqCnDc8IY+Q3DSg604wB3dBH0CaZlj4nZWHWuxtZ3NRZ9c9WUrz1Vfm6wioAUnqL3bsh49uQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/normalize-package-data": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
@@ -5967,19 +5958,6 @@
"minimist": "^1.2.5"
}
},
"moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"moment-timezone": {
"version": "0.5.33",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz",
"integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==",
"requires": {
"moment": ">= 2.9.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -6067,14 +6045,6 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node-cron": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.0.tgz",
"integrity": "sha512-DDwIvvuCwrNiaU7HEivFDULcaQualDv7KoNlB/UU1wPW0n1tDEmBJKhEIE6DlF2FuoOHcNbLJ8ITL2Iv/3AWmA==",
"requires": {
"moment-timezone": "^0.5.31"
}
},
"node-env-file": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/node-env-file/-/node-env-file-0.1.8.tgz",
@@ -6178,11 +6148,6 @@
}
}
},
"nodemailer": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.6.0.tgz",
"integrity": "sha512-ikSMDU1nZqpo2WUPE0wTTw/NGGImTkwpJKDIFPZT+YvvR9Sj+ze5wzu95JHkBMglQLoG2ITxU21WukCC/XsFkg=="
},
"nodemon": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz",

View File

@@ -3,7 +3,7 @@
"version": "2.0.1",
"private": true,
"scripts": {
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
"start": "node dist/app.js",
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
"tsc": "tsc --project tsconfig.json",
@@ -23,7 +23,6 @@
"dayjs": "^1.9.8",
"formidable": "^1.2.2",
"fs-extra": "^8.1.0",
"handlebars": "^4.7.7",
"html-entities": "^1.3.1",
"knex": "0.95.4",
"koa": "^2.8.1",
@@ -31,13 +30,11 @@
"mustache": "^3.1.0",
"nanoid": "^2.1.1",
"node-env-file": "^0.1.8",
"nodemailer": "^6.6.0",
"nodemon": "^2.0.6",
"pg": "^8.5.1",
"pretty-bytes": "^5.6.0",
"query-string": "^6.8.3",
"sqlite3": "^4.1.0",
"node-cron": "^3.0.0",
"yargs": "^14.0.0"
},
"devDependencies": {
@@ -49,7 +46,6 @@
"@types/koa": "^2.0.49",
"@types/markdown-it": "^12.0.0",
"@types/mustache": "^0.8.32",
"@types/nodemailer": "^6.4.1",
"@types/yargs": "^13.0.2",
"jest": "^26.6.3",
"jsdom": "^16.4.0",

Binary file not shown.

View File

@@ -67,21 +67,10 @@ function markPasswords(o: Record<string, any>): Record<string, any> {
return output;
}
async function getEnvFilePath(env: Env, argv: any): Promise<string> {
if (argv.envFile) return argv.envFile;
if (env === Env.Dev) {
const envFilePath = `${require('os').homedir()}/joplin-credentials/server.env`;
if (await fs.pathExists(envFilePath)) return envFilePath;
}
return '';
}
async function main() {
const envFilePath = await getEnvFilePath(env, argv);
if (envFilePath) nodeEnvFile(envFilePath);
if (argv.envFile) {
nodeEnvFile(argv.envFile);
}
if (!envVariables[env]) throw new Error(`Invalid env: ${env}`);
@@ -102,8 +91,6 @@ async function main() {
});
Logger.initializeGlobalLogger(globalLogger);
if (envFilePath) appLogger().info(`Env variables were loaded from: ${envFilePath}`);
const pidFile = argv.pidfile as string;
if (pidFile) {
@@ -142,7 +129,7 @@ async function main() {
const appContext = app.context as AppContext;
await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
await initializeJoplinUtils(config(), appContext.models, appContext.services.mustache);
await initializeJoplinUtils(config(), appContext.models);
appLogger().info('Migrating database...');
await migrateDb(appContext.db);

View File

@@ -1,5 +1,5 @@
import { rtrimSlashes } from '@joplin/lib/path-utils';
import { Config, DatabaseConfig, DatabaseConfigClient, MailerConfig } from './utils/types';
import { Config, DatabaseConfig, DatabaseConfigClient } from './utils/types';
import * as pathUtils from 'path';
export interface EnvVariables {
@@ -14,15 +14,6 @@ export interface EnvVariables {
POSTGRES_HOST?: string;
POSTGRES_PORT?: string;
MAILER_ENABLED?: string;
MAILER_HOST?: string;
MAILER_PORT?: string;
MAILER_SECURE?: string;
MAILER_AUTH_USER?: string;
MAILER_AUTH_PASSWORD?: string;
MAILER_NOREPLY_NAME?: string;
MAILER_NOREPLY_EMAIL?: string;
SQLITE_DATABASE?: string;
}
@@ -66,19 +57,6 @@ function databaseConfigFromEnv(runningInDocker: boolean, env: EnvVariables): Dat
};
}
function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
return {
enabled: env.MAILER_ENABLED !== '0',
host: env.MAILER_HOST || '',
port: Number(env.MAILER_PORT || 587),
secure: !!Number(env.MAILER_SECURE) || true,
authUser: env.MAILER_AUTH_USER || '',
authPassword: env.MAILER_AUTH_PASSWORD || '',
noReplyName: env.MAILER_NOREPLY_NAME || '',
noReplyEmail: env.MAILER_NOREPLY_EMAIL || '',
};
}
function baseUrlFromEnv(env: any, appPort: number): string {
if (env.APP_BASE_URL) {
return rtrimSlashes(env.APP_BASE_URL);
@@ -103,7 +81,6 @@ export function initConfig(env: EnvVariables, overrides: any = null) {
tempDir: `${rootDir}/temp`,
logDir: `${rootDir}/logs`,
database: databaseConfigFromEnv(runningInDocker_, env),
mailer: mailerConfigFromEnv(env),
port: appPort,
baseUrl: baseUrlFromEnv(env, appPort),
...overrides,

View File

@@ -218,11 +218,6 @@ export enum ItemType {
User,
}
export enum EmailSender {
NoReply = 1,
Support = 2,
}
export enum ChangeType {
Create = 1,
Update = 2,
@@ -282,8 +277,6 @@ export interface User extends WithDates, WithUuid {
is_admin?: number;
max_item_size?: number;
can_share?: number;
email_confirmed?: number;
must_set_password?: number;
}
export interface Session extends WithDates, WithUuid {
@@ -377,25 +370,6 @@ export interface Change extends WithDates, WithUuid {
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;
}
export interface Token extends WithDates {
id?: number;
value?: string;
user_id?: Uuid;
}
export const databaseSchema: DatabaseTables = {
users: {
id: { type: 'string' },
@@ -407,8 +381,6 @@ export const databaseSchema: DatabaseTables = {
created_time: { type: 'string' },
max_item_size: { type: 'number' },
can_share: { type: 'number' },
email_confirmed: { type: 'number' },
must_set_password: { type: 'number' },
},
sessions: {
id: { type: 'string' },
@@ -513,26 +485,5 @@ export const databaseSchema: DatabaseTables = {
previous_item: { 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' },
},
tokens: {
id: { type: 'number' },
value: { type: 'string' },
user_id: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@@ -19,7 +19,7 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
ctx.owner.id,
'change_admin_password',
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', ctx.models.user().profileUrl())
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
);
} else {
await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');

View File

@@ -1,15 +1,15 @@
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import { isView, View } from '../services/MustacheService';
// import config from '../config';
import MustacheService, { isView, View } from '../services/MustacheService';
import config from '../config';
// let mustache_: MustacheService = null;
// function mustache(): MustacheService {
// if (!mustache_) {
// mustache_ = new MustacheService(config().viewDir, config().baseUrl);
// }
// return mustache_;
// }
let mustache_: MustacheService = null;
function mustache(): MustacheService {
if (!mustache_) {
mustache_ = new MustacheService(config().viewDir, config().baseUrl);
}
return mustache_;
}
export default async function(ctx: AppContext) {
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
@@ -21,7 +21,7 @@ export default async function(ctx: AppContext) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
ctx.response.status = 200;
ctx.response.body = await ctx.services.mustache.renderView(responseObject, {
ctx.response.body = await mustache().renderView(responseObject, {
notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner,
@@ -55,7 +55,7 @@ export default async function(ctx: AppContext) {
owner: ctx.owner,
},
};
ctx.response.body = await ctx.services.mustache.renderView(view);
ctx.response.body = await mustache().renderView(view);
} else { // JSON
ctx.response.set('Content-Type', 'application/json');
const r: any = { error: error.message };

View File

@@ -1,47 +0,0 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) {
table.integer('email_confirmed').defaultTo(0).notNullable();
table.integer('must_set_password').defaultTo(0).notNullable();
});
await db.schema.createTable('emails', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.text('recipient_name', 'mediumtext').defaultTo('').notNullable();
table.text('recipient_email', 'mediumtext').defaultTo('').notNullable();
table.string('recipient_id', 32).defaultTo(0).notNullable();
table.integer('sender_id').notNullable();
table.string('subject', 128).notNullable();
table.text('body').notNullable();
table.bigInteger('sent_time').defaultTo(0).notNullable();
table.integer('sent_success').defaultTo(0).notNullable();
table.text('error').defaultTo('').notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.createTable('tokens', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.string('value', 32).notNullable();
table.string('user_id', 32).defaultTo('').notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.alterTable('emails', function(table: Knex.CreateTableBuilder) {
table.index(['sent_time']);
table.index(['sent_success']);
});
await db('users').update({ email_confirmed: 1 });
await db.schema.alterTable('tokens', function(table: Knex.CreateTableBuilder) {
table.index(['value', 'user_id']);
});
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@@ -272,10 +272,10 @@ export default abstract class BaseModel<T> {
return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
}
public async delete(id: string | string[] | number | number[], options: DeleteOptions = {}): Promise<void> {
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
if (!id) throw new Error('id cannot be empty');
const ids = (typeof id === 'string' || typeof id === 'number') ? [id] : id;
const ids = typeof id === 'string' ? [id] : id;
if (!ids.length) throw new Error('no id provided');

View File

@@ -1,33 +0,0 @@
import { Uuid, Email, EmailSender } from '../db';
import BaseModel from './BaseModel';
export interface EmailToSend {
sender_id: EmailSender;
recipient_email: string;
subject: string;
body: string;
recipient_name?: string;
recipient_id?: Uuid;
}
export default class EmailModel extends BaseModel<Email> {
public get tableName(): string {
return 'emails';
}
protected hasUuid(): boolean {
return false;
}
public async push(email: EmailToSend) {
EmailModel.eventEmitter.emit('saved');
return super.save({ ...email });
}
public async needToBeSent(): Promise<Email[]> {
return this.db(this.tableName).where('sent_time', '=', 0);
}
}

View File

@@ -1,30 +0,0 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
describe('TokenModel', function() {
beforeAll(async () => {
await beforeAllDb('TokenModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should delete old tokens', async function() {
const { user: user1 } = await createUserAndSession(1);
await models().token().generate(user1.id);
const [token1, token2] = await models().token().all();
await models().token().save({ id: token1.id, created_time: Date.now() - 2629746000 });
await models().token().deleteExpiredTokens();
const tokens = await models().token().all();
expect(tokens.length).toBe(1);
expect(tokens[0].id).toBe(token2.id);
});
});

View File

@@ -1,62 +0,0 @@
import { Token, Uuid } from '../db';
import { ErrorForbidden } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import BaseModel from './BaseModel';
export default class TokenModel extends BaseModel<Token> {
private tokenTtl_: number = 7 * 24 * 60 * 1000;
public get tableName(): string {
return 'tokens';
}
protected hasUuid(): boolean {
return false;
}
public async generate(userId: Uuid): Promise<string> {
const token = await this.save({
value: uuidgen(32),
user_id: userId,
});
return token.value;
}
public async checkToken(userId: string, tokenValue: string): Promise<void> {
if (!(await this.isValid(userId, tokenValue))) throw new ErrorForbidden('Invalid or expired token');
}
private async byUser(userId: string, tokenValue: string): Promise<Token> {
return this
.db(this.tableName)
.select(['id'])
.where('user_id', '=', userId)
.where('value', '=', tokenValue)
.first();
}
public async isValid(userId: string, tokenValue: string): Promise<boolean> {
const token = await this.byUser(userId, tokenValue);
return !!token;
}
public async deleteExpiredTokens() {
const cutOffDate = Date.now() - this.tokenTtl_;
await this.db(this.tableName).where('created_time', '<', cutOffDate).delete();
}
public async deleteByValue(userId: Uuid, value: string) {
const token = await this.byUser(userId, value);
if (token) await this.delete(token.id);
}
public async allByUserId(userId: Uuid): Promise<Token[]> {
return this
.db(this.tableName)
.select(this.defaultFields)
.where('user_id', '=', userId);
}
}

View File

@@ -1,5 +1,5 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
import { EmailSender, User } from '../db';
import { User } from '../db';
import { ErrorUnprocessableEntity } from '../utils/errors';
describe('UserModel', function() {
@@ -68,22 +68,4 @@ describe('UserModel', function() {
expect((await models().userItem().all()).length).toBe(0);
});
test('should push an email when creating a new user', async function() {
const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const emails = await models().email().all();
expect(emails.length).toBe(2);
expect(emails.find(e => e.recipient_email === user1.email)).toBeTruthy();
expect(emails.find(e => e.recipient_email === user2.email)).toBeTruthy();
const email = emails[0];
expect(email.subject.trim()).toBeTruthy();
expect(email.body.includes('/confirm?token=')).toBeTruthy();
expect(email.sender_id).toBe(EmailSender.NoReply);
expect(email.sent_success).toBe(0);
expect(email.sent_time).toBe(0);
expect(email.error).toBe('');
});
});

View File

@@ -1,7 +1,7 @@
import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel';
import { EmailSender, Item, User, Uuid } from '../db';
import { Item, User } from '../db';
import * as auth from '../utils/auth';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge } from '../utils/errors';
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import prettyBytes = require('pretty-bytes');
@@ -134,14 +134,10 @@ export default class UserModel extends BaseModel<User> {
return !!s[0].length && !!s[1].length;
}
public profileUrl(): string {
public async profileUrl(): Promise<string> {
return `${this.baseUrl}/users/me`;
}
public confirmUrl(userId: Uuid, validationToken: string): string {
return `${this.baseUrl}/users/${userId}/confirm?token=${validationToken}`;
}
public async delete(id: string): Promise<void> {
const shares = await this.models().share().sharesByUser(id);
@@ -155,13 +151,6 @@ export default class UserModel extends BaseModel<User> {
}, 'UserModel::delete');
}
public async confirmEmail(userId: Uuid, token: string) {
await this.models().token().checkToken(userId, token);
const user = await this.models().user().load(userId);
if (!user) throw new ErrorNotFound('No such user');
await this.save({ id: user.id, email_confirmed: 1 });
}
// Note that when the "password" property is provided, it is going to be
// hashed automatically. It means that it is not safe to do:
//
@@ -171,30 +160,8 @@ export default class UserModel extends BaseModel<User> {
// Because the password would be hashed twice.
public async save(object: User, options: SaveOptions = {}): Promise<User> {
const user = { ...object };
if (user.password) user.password = auth.hashPassword(user.password);
const isNew = await this.isNew(object, options);
return this.withTransaction(async () => {
const savedUser = await super.save(user, options);
if (isNew) {
const validationToken = await this.models().token().generate(savedUser.id);
const validationUrl = encodeURI(this.confirmUrl(savedUser.id, validationToken));
await this.models().email().push({
sender_id: EmailSender.NoReply,
recipient_id: savedUser.id,
recipient_email: savedUser.email,
recipient_name: savedUser.full_name || '',
subject: 'Verify your email',
body: `Click this: ${validationUrl}`,
});
}
return savedUser;
});
return super.save(user, options);
}
}

View File

@@ -63,11 +63,9 @@ import SessionModel from './SessionModel';
import ChangeModel from './ChangeModel';
import NotificationModel from './NotificationModel';
import ShareModel from './ShareModel';
import EmailModel from './EmailModel';
import ItemResourceModel from './ItemResourceModel';
import ShareUserModel from './ShareUserModel';
import KeyValueModel from './KeyValueModel';
import TokenModel from './TokenModel';
export class Models {
@@ -87,18 +85,10 @@ export class Models {
return new UserModel(this.db_, newModelFactory, this.baseUrl_);
}
public email() {
return new EmailModel(this.db_, newModelFactory, this.baseUrl_);
}
public userItem() {
return new UserItemModel(this.db_, newModelFactory, this.baseUrl_);
}
public token() {
return new TokenModel(this.db_, newModelFactory, this.baseUrl_);
}
public itemResource() {
return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_);
}

View File

@@ -30,9 +30,8 @@ router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
const user = await postedUserFromContext(ctx);
// We set a random password because it's required, but user will have to
// set it after clicking on the confirmation link.
// set it by clicking on the confirmation link.
user.password = uuidgen();
user.must_set_password = 1;
const output = await ctx.models.user().save(user);
return ctx.models.user().toApiOutput(output);
});

View File

@@ -6,7 +6,7 @@ import { PaginationOrderDir } from '../../models/utils/pagination';
import { formatDateTime } from '../../utils/time';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { makeTablePagination, Table, Row, makeTableView } from '../../utils/views/table';
import { makeTablePagination, Table, Row, makeTableView, tablePartials } from '../../utils/views/table';
const router = new Router();
@@ -57,6 +57,7 @@ router.get('changes', async (_path: SubPath, ctx: AppContext) => {
const view: View = defaultView('changes');
view.content.changeTable = makeTableView(table),
view.cssFiles = ['index/changes'];
view.partials = view.partials.concat(tablePartials());
return view;
});

View File

@@ -7,7 +7,7 @@ import config from '../../config';
import { formatDateTime } from '../../utils/time';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { makeTablePagination, makeTableView, Row, Table, tablePartials } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
const prettyBytes = require('pretty-bytes');
@@ -67,6 +67,7 @@ router.get('items', async (_path: SubPath, ctx: AppContext) => {
view.content.itemTable = makeTableView(table),
view.content.postUrl = `${config().baseUrl}/items`;
view.cssFiles = ['index/items'];
view.partials = view.partials.concat(tablePartials());
return view;
});

View File

@@ -9,7 +9,7 @@ import { View } from '../../services/MustacheService';
function makeView(error: any = null): View {
const view = defaultView('login');
view.content.error = error;
view.navbar = false;
view.partials = ['errorBanner'];
return view;
}

View File

@@ -1,7 +1,7 @@
import { User } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { ErrorForbidden } from '../../utils/errors';
import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { execRequest } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
export async function postUser(sessionId: string, email: string, password: string): Promise<User> {
@@ -153,76 +153,6 @@ describe('index_users', function() {
expect(result).toContain(user2.email);
});
test('should allow user to set a password for new accounts', async function() {
const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const email = (await models().email().all()).find(e => e.recipient_id === user1.id);
const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
const path = matches[1];
const token = matches[3];
// Check that the token is valid
expect(await models().token().isValid(user1.id, token)).toBe(true);
// Check that we can't set the password without the token
{
const context = await execRequestC('', 'POST', path, {
password: 'newpassword',
password2: 'newpassword',
});
const sessionId = context.cookies.get('sessionId');
expect(sessionId).toBeFalsy();
}
// Check that we can't set the password with someone else's token
{
const token2 = (await models().token().allByUserId(user2.id))[0].value;
const context = await execRequestC('', 'POST', path, {
password: 'newpassword',
password2: 'newpassword',
token: token2,
});
const sessionId = context.cookies.get('sessionId');
expect(sessionId).toBeFalsy();
}
const context = await execRequestC('', 'POST', path, {
password: 'newpassword',
password2: 'newpassword',
token: token,
});
// Check that the user has been logged in
const sessionId = context.cookies.get('sessionId');
const session = await models().session().load(sessionId);
expect(session.user_id).toBe(user1.id);
// Check that the password has been set
const loggedInUser = await models().user().login(user1.email, 'newpassword');
expect(loggedInUser.id).toBe(user1.id);
// Check that the token has been cleared
expect(await models().token().isValid(user1.id, token)).toBe(false);
// Check that a notification has been created
const notification = (await models().notification().all())[0];
expect(notification.key).toBe('passwordSet');
});
// test('should handle invalid email validation', async function() {
// await createUserAndSession(1);
// const email = (await models().email().all())[0];
// const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
// const path = matches[1];
// const token = matches[3];
// // Valid path but invalid token
// await expectHttpError(async () => execRequest(null, 'GET', path, null, { query: { token: 'invalid' } }), ErrorNotFound.httpCode);
// // Valid token but invalid path
// await expectHttpError(async () => execRequest(null, 'GET', 'users/abcd1234/confirm', null, { query: { token } }), ErrorNotFound.httpCode);
// });
test('should apply ACL', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
const { user: user1, session: session1 } = await createUserAndSession(2, false);

View File

@@ -1,26 +1,15 @@
import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext, HttpMethod } from '../../utils/types';
import { bodyFields, formParse } from '../../utils/requestUtils';
import { formParse } from '../../utils/requestUtils';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
import { NotificationLevel, User } from '../../db';
import { User } from '../../db';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
const prettyBytes = require('pretty-bytes');
function checkPassword(fields: SetPasswordFormData, required: boolean): string {
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
return fields.password;
} else {
if (required) throw new ErrorUnprocessableEntity('Password is required');
}
return '';
}
function makeUser(isNew: boolean, fields: any): User {
const user: User = {};
@@ -30,8 +19,10 @@ function makeUser(isNew: boolean, fields: any): User {
if ('max_item_size' in fields) user.max_item_size = fields.max_item_size;
user.can_share = fields.can_share ? 1 : 0;
const password = checkPassword(fields, false);
if (password) user.password = password;
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
user.password = fields.password;
}
if (!isNew) user.id = fields.id;
@@ -92,63 +83,11 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
view.partials.push('errorBanner');
return view;
});
router.publicSchemas.push('users/:id/confirm');
router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Error = null) => {
const userId = path.id;
const token = ctx.query.token;
if (token) await ctx.models.user().confirmEmail(userId, token);
const user = await ctx.models.user().load(userId);
const view: View = {
...defaultView('users/confirm'),
content: {
user,
error,
token,
postUrl: ctx.models.user().confirmUrl(userId, token),
},
navbar: false,
};
return view;
});
interface SetPasswordFormData {
token: string;
password: string;
password2: string;
}
router.post('users/:id/confirm', async (path: SubPath, ctx: AppContext) => {
const userId = path.id;
try {
const fields = await bodyFields<SetPasswordFormData>(ctx.req);
await ctx.models.token().checkToken(userId, fields.token);
const password = checkPassword(fields, true);
await ctx.models.user().save({ id: userId, password });
await ctx.models.token().deleteByValue(userId, fields.token);
const session = await ctx.models.session().createUserSession(userId);
ctx.cookies.set('sessionId', session.id);
await ctx.models.notification().add(userId, 'passwordSet', NotificationLevel.Normal, 'Welcome to Joplin Cloud! Your password has been set successfully.');
return redirect(ctx, `${config().baseUrl}/home`);
} catch (error) {
const endPoint = router.findEndPoint(HttpMethod.GET, 'users/:id/confirm');
return endPoint(path, ctx, error);
}
});
router.alias(HttpMethod.POST, 'users/:id', 'users');
router.post('users', async (path: SubPath, ctx: AppContext) => {

View File

@@ -0,0 +1,45 @@
import { Models } from '../models/factory';
import { Config } from '../utils/types';
import MustacheService from './MustacheService';
export default class BaseApplication {
private appName_: string;
private config_: Config = null;
private models_: Models = null;
private mustache_: MustacheService = null;
private rootDir_: string;
protected get mustache(): MustacheService {
return this.mustache_;
}
protected get config(): Config {
return this.config_;
}
protected get models(): Models {
return this.models_;
}
public get rootDir(): string {
return this.rootDir_;
}
public get appBaseUrl(): string {
return `${this.config.baseUrl}/apps/${this.appName_}`;
}
public initBase_(appName: string, config: Config, models: Models) {
this.appName_ = appName;
this.rootDir_ = `${config.rootDir}/src/apps/${appName}`;
this.config_ = config;
this.models_ = models;
this.mustache_ = new MustacheService(`${this.rootDir}/views`, `${config.baseUrl}/apps/${appName}`);
}
public async localFileFromUrl(_url: string): Promise<string> {
return null;
}
}

View File

@@ -1,96 +0,0 @@
import Logger from '@joplin/lib/Logger';
import { Models } from '../models/factory';
import { msleep } from '../utils/time';
import { Config, Env } from '../utils/types';
const logger = Logger.create('BaseService');
export default class BaseService {
private env_: Env;
private models_: Models;
private config_: Config;
protected enabled_: boolean = true;
private destroyed_: boolean = false;
protected maintenanceInterval_: number = 10000;
private scheduledMaintenances_: boolean[] = [];
private maintenanceInProgress_: boolean = false;
public constructor(env: Env, models: Models, config: Config) {
this.env_ = env;
this.models_ = models;
this.config_ = config;
this.scheduleMaintenance = this.scheduleMaintenance.bind(this);
}
public async destroy() {
if (this.destroyed_) throw new Error('Already destroyed');
this.destroyed_ = true;
this.scheduledMaintenances_ = [];
while (this.maintenanceInProgress_) {
await msleep(500);
}
}
protected get models(): Models {
return this.models_;
}
protected get env(): Env {
return this.env_;
}
protected get config(): Config {
return this.config_;
}
public get enabled(): boolean {
return this.enabled_;
}
public get maintenanceInProgress(): boolean {
return !!this.scheduledMaintenances_.length;
}
protected async scheduleMaintenance() {
if (this.destroyed_) return;
// Every time a maintenance is scheduled we push a task to this array.
// Whenever the maintenance actually runs, that array is cleared. So it
// means, that if new tasks are pushed to the array while the
// maintenance is runing, it will run again once it's finished, so as to
// process any item that might have been added.
this.scheduledMaintenances_.push(true);
if (this.scheduledMaintenances_.length !== 1) return;
while (this.scheduledMaintenances_.length) {
await msleep(this.env === Env.Dev ? 2000 : this.maintenanceInterval_);
if (this.destroyed_) return;
const itemCount = this.scheduledMaintenances_.length;
await this.runMaintenance();
this.scheduledMaintenances_.splice(0, itemCount);
}
}
private async runMaintenance() {
this.maintenanceInProgress_ = true;
try {
await this.maintenance();
} catch (error) {
logger.error('Could not run maintenance', error);
}
this.maintenanceInProgress_ = false;
}
protected async maintenance() {
throw new Error('Not implemented');
}
public async runInBackground() {
await this.runMaintenance();
}
}

View File

@@ -1,12 +0,0 @@
import BaseService from './BaseService';
const cron = require('node-cron');
export default class CronService extends BaseService {
public async runInBackground() {
cron.schedule('0 */6 * * *', async () => {
await this.models.token().deleteExpiredTokens();
});
}
}

View File

@@ -1,122 +0,0 @@
import Logger from '@joplin/lib/Logger';
import UserModel from '../models/UserModel';
import BaseService from './BaseService';
import Mail = require('nodemailer/lib/mailer');
import { createTransport } from 'nodemailer';
import { Email, EmailSender } from '../db';
import { errorToString } from '../utils/errors';
const logger = Logger.create('EmailService');
interface Participant {
name: string;
email: string;
}
export default class EmailService extends BaseService {
private transport_: any;
private async transport(): Promise<Mail> {
if (!this.transport_) {
this.transport_ = createTransport({
host: this.config.mailer.host,
port: this.config.mailer.port,
secure: this.config.mailer.secure,
auth: {
user: this.config.mailer.authUser,
pass: this.config.mailer.authPassword,
},
});
try {
await this.transport_.verify();
} catch (error) {
this.enabled_ = false;
this.transport_ = null;
error.message = `Could not initialize transporter. Service will be disabled: ${error.message}`;
throw error;
}
}
return this.transport_;
}
private senderInfo(senderId: EmailSender): Participant {
if (senderId === EmailSender.NoReply) {
return {
name: this.config.mailer.noReplyName,
email: this.config.mailer.noReplyEmail,
};
}
throw new Error(`Invalid sender ID: ${senderId}`);
}
private escapeEmailField(f: string): string {
return f.replace(/[\n\r"<>]/g, '');
}
private formatNameAndEmail(email: string, name: string = ''): string {
if (!email) throw new Error('Email is required');
const output: string[] = [];
if (name) output.push(`"${this.escapeEmailField(name)}"`);
output.push((name ? '<' : '') + this.escapeEmailField(email) + (name ? '>' : ''));
return output.join(' ');
}
protected async maintenance() {
if (!this.enabled_) return;
logger.info('Starting maintenance...');
const startTime = Date.now();
try {
const emails = await this.models.email().needToBeSent();
const transport = await this.transport();
for (const email of emails) {
const sender = this.senderInfo(email.sender_id);
const mailOptions: Mail.Options = {
from: this.formatNameAndEmail(sender.email, sender.name),
to: this.formatNameAndEmail(email.recipient_email, email.recipient_name),
subject: email.subject,
text: email.body,
};
const emailToSave: Email = {
id: email.id,
sent_time: Date.now(),
};
try {
await transport.sendMail(mailOptions);
emailToSave.sent_success = 1;
emailToSave.error = '';
} catch (error) {
emailToSave.sent_success = 0;
emailToSave.error = errorToString(error);
}
await this.models.email().save(emailToSave);
}
} catch (error) {
logger.error('Could not run maintenance:', error);
}
logger.info(`Maintenance completed in ${Date.now() - startTime}ms`);
}
public async runInBackground() {
if (!this.config.mailer.host || !this.config.mailer.enabled) {
this.enabled_ = false;
logger.info('Service will be disabled because mailer config is not set or is explicitly disabled');
return;
}
UserModel.eventEmitter.on('created', this.scheduleMaintenance);
await super.runInBackground();
}
}

View File

@@ -1,9 +1,6 @@
import * as Mustache from 'mustache';
import * as fs from 'fs-extra';
import config from '../config';
import { filename } from '@joplin/lib/path-utils';
import { NotificationView } from '../utils/types';
import { User } from '../db';
export interface RenderOptions {
partials?: any;
@@ -14,21 +11,12 @@ export interface RenderOptions {
export interface View {
name: string;
path: string;
navbar?: boolean;
content?: any;
partials?: string[];
cssFiles?: string[];
jsFiles?: string[];
}
interface GlobalParams {
baseUrl?: string;
prefersDarkEnabled?: boolean;
notifications?: NotificationView[];
hasNotifications?: boolean;
owner?: User;
}
export function isView(o: any): boolean {
if (typeof o !== 'object' || !o) return false;
return 'path' in o && 'name' in o;
@@ -39,27 +27,12 @@ export default class MustacheService {
private viewDir_: string;
private baseAssetUrl_: string;
private prefersDarkEnabled_: boolean = true;
private partials_: Record<string, string> = {};
public constructor(viewDir: string, baseAssetUrl: string) {
this.viewDir_ = viewDir;
this.baseAssetUrl_ = baseAssetUrl;
}
public async loadPartials() {
const files = await fs.readdir(this.partialDir);
for (const f of files) {
const name = filename(f);
const templateContent = await this.loadTemplateContent(`${this.partialDir}/${f}`);
this.partials_[name] = templateContent;
}
}
public get partialDir(): string {
return `${this.viewDir_}/partials`;
}
public get prefersDarkEnabled(): boolean {
return this.prefersDarkEnabled_;
}
@@ -72,7 +45,7 @@ export default class MustacheService {
return `${config().layoutDir}/default.mustache`;
}
private get defaultLayoutOptions(): GlobalParams {
private get defaultLayoutOptions(): any {
return {
baseUrl: config().baseUrl,
prefersDarkEnabled: this.prefersDarkEnabled_,
@@ -91,9 +64,17 @@ export default class MustacheService {
return output;
}
public async renderView(view: View, globalParams: GlobalParams = null): Promise<string> {
public async renderView(view: View, globalParams: any = null): Promise<string> {
const partials = view.partials || [];
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
const partialContents: any = {};
for (const partialName of partials) {
const filePath = `${this.viewDir_}/partials/${partialName}.mustache`;
partialContents[partialName] = await this.loadTemplateContent(filePath);
}
const filePath = `${this.viewDir_}/${view.path}.mustache`;
globalParams = {
@@ -107,20 +88,19 @@ export default class MustacheService {
...view.content,
global: globalParams,
},
this.partials_
partialContents
);
const layoutView: any = {
const layoutView: any = Object.assign({}, {
global: globalParams,
pageName: view.name,
contentHtml: contentHtml,
cssFiles: cssFiles,
jsFiles: jsFiles,
navbar: view.navbar,
...view.content,
};
});
return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, this.partials_);
return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, partialContents);
}
}

View File

@@ -1,4 +1,3 @@
import config from '../config';
import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, updateNote, msleep } from '../utils/testing/testUtils';
import { Env } from '../utils/types';
@@ -24,7 +23,7 @@ describe('ShareService', function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
const service = new ShareService(Env.Dev, models(), config());
const service = new ShareService(Env.Dev, models());
void service.runInBackground();
await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F2', {

View File

@@ -1,27 +1,73 @@
import Logger from '@joplin/lib/Logger';
import ChangeModel from '../models/ChangeModel';
import BaseService from './BaseService';
import { Models } from '../models/factory';
import { Env } from '../utils/types';
const logger = Logger.create('ShareService');
export default class ShareService extends BaseService {
export default class ShareService {
private env_: Env;
private models_: Models;
private maintenanceScheduled_: boolean = false;
private maintenanceInProgress_: boolean = false;
private scheduleMaintenanceTimeout_: any = null;
public constructor(env: Env, models: Models) {
this.env_ = env;
this.models_ = models;
this.scheduleMaintenance = this.scheduleMaintenance.bind(this);
}
public async destroy() {
if (this.scheduleMaintenanceTimeout_) {
clearTimeout(this.scheduleMaintenanceTimeout_);
this.scheduleMaintenanceTimeout_ = null;
}
}
public get models(): Models {
return this.models_;
}
public get env(): Env {
return this.env_;
}
public get maintenanceInProgress(): boolean {
return this.maintenanceInProgress_;
}
private async scheduleMaintenance() {
if (this.maintenanceScheduled_) return;
this.maintenanceScheduled_ = true;
this.scheduleMaintenanceTimeout_ = setTimeout(() => {
this.maintenanceScheduled_ = false;
void this.maintenance();
}, this.env === Env.Dev ? 2000 : 10000);
}
private async maintenance() {
if (this.maintenanceInProgress_) return;
protected async maintenance() {
logger.info('Starting maintenance...');
const startTime = Date.now();
this.maintenanceInProgress_ = true;
try {
await this.models.share().updateSharedItems3();
} catch (error) {
logger.error('Could not update share items:', error);
}
this.maintenanceInProgress_ = false;
logger.info(`Maintenance completed in ${Date.now() - startTime}ms`);
}
public async runInBackground() {
ChangeModel.eventEmitter.on('saved', this.scheduleMaintenance);
await super.runInBackground();
await this.maintenance();
}
}

View File

@@ -1,11 +1,5 @@
import CronService from './CronService';
import EmailService from './EmailService';
import MustacheService from './MustacheService';
import ShareService from './ShareService';
export interface Services {
share: ShareService;
email: EmailService;
cron: CronService;
mustache: MustacheService;
}

View File

@@ -31,8 +31,6 @@ const config = {
'main.shares': 'WithDates, WithUuid',
'main.share_users': 'WithDates, WithUuid',
'main.user_items': 'WithDates',
'main.emails': 'WithDates',
'main.tokens': 'WithDates',
},
};
@@ -43,8 +41,6 @@ const propertyTypes: Record<string, string> = {
'shares.type': 'ShareType',
'items.content': 'Buffer',
'share_users.status': 'ShareUserStatus',
'emails.sender_id': 'EmailSender',
'emails.sent_time': 'number',
};
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {

View File

@@ -9,7 +9,6 @@ export default class Router {
// not logged in, can access any route of this router. End points that
// should not be publicly available should call ownerRequired(ctx);
public public: boolean = false;
public publicSchemas: string[] = [];
public responseFormat: RouteResponseFormat = null;
@@ -35,10 +34,6 @@ export default class Router {
throw new ErrorNotFound(`Could not resolve: ${method} ${schema}`);
}
public isPublic(schema: string): boolean {
return this.public || this.publicSchemas.includes(schema);
}
public alias(method: HttpMethod, path: string, target: string) {
if (!this.aliases_[method]) { this.aliases_[method] = {}; }
this.aliases_[method][path] = target;

View File

@@ -6,6 +6,9 @@ export default function(name: string): View {
name: name,
path: `index/${name}`,
content: {},
navbar: true,
partials: [
'navbar',
'notifications',
],
};
}

View File

@@ -86,10 +86,3 @@ export class ErrorPayloadTooLarge extends ApiError {
Object.setPrototypeOf(this, ErrorPayloadTooLarge.prototype);
}
}
export function errorToString(error: Error): string {
const msg: string[] = [];
msg.push(error.message ? error.message : 'Unknown error');
if (error.stack) msg.push(error.stack);
return msg.join(': ');
}

View File

@@ -1,5 +1,5 @@
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
// import Logger from '@joplin/lib/Logger';
import Logger from '@joplin/lib/Logger';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
@@ -24,7 +24,7 @@ import Setting from '@joplin/lib/models/Setting';
import { Models } from '../models/factory';
import MustacheService from '../services/MustacheService';
// const logger = Logger.create('JoplinUtils');
const logger = Logger.create('JoplinUtils');
export interface FileViewerResponse {
body: any;
@@ -55,16 +55,15 @@ let baseUrl_: string = null;
export const resourceDirName = '.resource';
export async function initializeJoplinUtils(config: Config, models: Models, mustache: MustacheService) {
export async function initializeJoplinUtils(config: Config, models: Models) {
models_ = models;
baseUrl_ = config.baseUrl;
mustache_ = mustache;
const filePath = `${config.tempDir}/joplin.sqlite`;
await fs.remove(filePath);
db_ = new JoplinDatabase(new DatabaseDriverNode());
// db_.setLogger(logger as Logger);
db_.setLogger(logger as Logger);
await db_.open({ name: filePath });
BaseModel.setDb(db_);
@@ -79,8 +78,8 @@ export async function initializeJoplinUtils(config: Config, models: Models, must
BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision);
// mustache_ = new MustacheService(config.viewDir, config.baseUrl);
// mustache_.prefersDarkEnabled = false;
mustache_ = new MustacheService(config.viewDir, config.baseUrl);
mustache_.prefersDarkEnabled = false;
}
export function linkedResourceIds(body: string): string[] {
@@ -211,7 +210,7 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
};
`,
},
}, { prefersDarkEnabled: false });
});
return {
body: bodyHtml,

View File

@@ -39,20 +39,26 @@ export async function bodyFields<T>(req: any/* , filter:string[] = null*/): Prom
// Formidable needs the content-type to be 'application/json' so on our side
// we explicitely set it to that. However save the previous value so that it
// can be restored.
// console.info('FFFFFFFFFFF', req.headers);
// let previousContentType = null;
// // if (req.headers['content-type'] !== 'application/json') {
// if (!req.headers['content-type']) {
// previousContentType = req.headers['content-type'];
// req.headers['content-type'] = 'application/json';
// }
let previousContentType = null;
if (req.headers['content-type'] !== 'application/json') {
previousContentType = req.headers['content-type'];
req.headers['content-type'] = 'application/json';
}
const form = await formParse(req);
// if (previousContentType) req.headers['content-type'] = previousContentType;
if (previousContentType) req.headers['content-type'] = previousContentType;
return form.fields as T;
// if (filter) {
// const output:BodyFields = {};
// Object.keys(form.fields).forEach(f => {
// if (filter.includes(f)) output[f] = form.fields[f];
// });
// return output;
// } else {
// return form.fields;
// }
}
export function ownerRequired(ctx: AppContext) {

View File

@@ -171,7 +171,7 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
// This is a generic catch-all for all private end points - if we
// couldn't get a valid session, we exit now. Individual end points
// might have additional permission checks depending on the action.
if (!match.route.isPublic(match.subPath.schema) && !ctx.owner) throw new ErrorForbidden();
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
return routeHandler(match.subPath, ctx);
}

View File

@@ -2,32 +2,22 @@ import { LoggerWrapper } from '@joplin/lib/Logger';
import config from '../config';
import { DbConnection } from '../db';
import newModelFactory, { Models } from '../models/factory';
import { AppContext, Config, Env } from './types';
import { AppContext, Env } from './types';
import routes from '../routes/routes';
import ShareService from '../services/ShareService';
import { Services } from '../services/types';
import EmailService from '../services/EmailService';
import CronService from '../services/CronService';
import MustacheService from '../services/MustacheService';
async function setupServices(env: Env, models: Models, config: Config): Promise<Services> {
const output: Services = {
share: new ShareService(env, models, config),
email: new EmailService(env, models, config),
cron: new CronService(env, models, config),
mustache: new MustacheService(config.viewDir, config.baseUrl),
function setupServices(env: Env, models: Models): Services {
return {
share: new ShareService(env, models),
};
await output.mustache.loadPartials();
return output;
}
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) {
appContext.env = env;
appContext.db = dbConnection;
appContext.models = newModelFactory(appContext.db, config().baseUrl);
appContext.services = await setupServices(env, appContext.models, config());
appContext.services = setupServices(env, appContext.models);
appContext.appLogger = appLogger;
appContext.routes = { ...routes };

View File

@@ -1,9 +1,5 @@
import { AppContext } from './types';
export default async function startServices(appContext: AppContext) {
const services = appContext.services;
void services.share.runInBackground();
void services.email.runInBackground();
void services.cron.runInBackground();
export default function startServices(appContext: AppContext) {
void appContext.services.share.runInBackground();
}

View File

@@ -17,7 +17,6 @@ import { putApi } from './apiUtils';
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import { ModelType } from '@joplin/lib/BaseModel';
import { initializeJoplinUtils } from '../joplinUtils';
import MustacheService from '../../services/MustacheService';
// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
@@ -85,10 +84,7 @@ export async function beforeAllDb(unitName: string) {
await createDb(config().database, { dropIfExists: true });
db_ = await connectDb(config().database);
const mustache = new MustacheService(config().viewDir, config().baseUrl);
await mustache.loadPartials();
await initializeJoplinUtils(config(), models(), mustache);
await initializeJoplinUtils(config(), models());
}
export async function afterAllTests() {

View File

@@ -44,29 +44,17 @@ export interface DatabaseConfig {
asyncStackTraces?: boolean;
}
export interface MailerConfig {
enabled: boolean;
host: string;
port: number;
secure: boolean;
authUser: string;
authPassword: string;
noReplyName: string;
noReplyEmail: string;
}
export interface Config {
port: number;
rootDir: string;
viewDir: string;
layoutDir: string;
// Note that, for now, nothing is being logged to file. Log is just printed
// Not that, for now, nothing is being logged to file. Log is just printed
// to stdout, which is then handled by Docker own log mechanism
logDir: string;
tempDir: string;
baseUrl: string;
database: DatabaseConfig;
mailer: MailerConfig;
baseUrl: string;
}
export enum HttpMethod {

View File

@@ -7,10 +7,10 @@
{{/stack}}
</div>
{{#owner}}
<p><a href="{{{global.baseUrl}}}/home">Go to home page</a></p>
<p><a href="{{{global.baseUrl}}}/home">Back to home page</a></p>
{{/owner}}
{{^owner}}
<p><a href="{{{global.baseUrl}}}/login">Go to login page</a></p>
<p><a href="{{{global.baseUrl}}}/login">Back to login page</a></p>
{{/owner}}
</div>
</div>

View File

@@ -1,33 +0,0 @@
<section class="section">
<div class="container">
<h1 class="title">Welcome to Joplin!</h1>
<h2 class="subtitle">Please enter your password to start using your account.</h2>
<form action="{{postUrl}}" method="POST">
<input class="input" type="hidden" name="token" value="{{token}}"/>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" disabled value="{{user.email}}"/>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password"/>
</div>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input class="input" type="password" name="password2"/>
</div>
</div>
{{> errorBanner}}
<div class="control">
<button class="button is-primary">Save password</button>
</div>
</form>
</div>
</section>

View File

@@ -1,8 +1,8 @@
{{#error}}
<div class="notification is-danger">
<strong>{{message}}</strong>
{{#stack}}
<strong>{{error.message}}</strong>
{{#error.stack}}
<pre>{{.}}</pre>
{{/stack}}
{{/error.stack}}
</div>
{{/error}}

View File

@@ -1,28 +1,26 @@
{{#navbar}}
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
</a>
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
</a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a>
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
{{/global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a>
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
{{/global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div>
<div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div>
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a>
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>
</form>
</div>
<div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div>
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a>
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>
</form>
</div>
</div>
</nav>
{{/navbar}}
</div>
</nav>

View File

@@ -6,13 +6,13 @@ msgid ""
msgstr ""
"Project-Id-Version: Joplin-CLI 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: 南宫小骏 <jackytsu@vip.qq.com>\n"
"Language-Team: zh_CN <jackytsu.vip.qq.com>\n"
"Last-Translator: Yang Zhang <zyangmath@gmail.com>\n"
"Language-Team: zh_CN <yaozeye@yaozeye.onmicrosoft.com>\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.3\n"
"X-Generator: Poedit 2.4.2\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
@@ -119,11 +119,11 @@ msgstr "下载"
#: packages/app-desktop/checkForUpdates.js:189
msgid "Skip this version"
msgstr "跳过该版本"
msgstr ""
#: packages/app-desktop/checkForUpdates.js:189
msgid "Full changelog"
msgstr "完整更新记录"
msgstr ""
#: packages/app-desktop/gui/NoteRevisionViewer.min.js:75
#, javascript-format
@@ -267,10 +267,12 @@ msgid "Retry"
msgstr "重试"
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:113
#, fuzzy
msgid "Advanced tools"
msgstr "高级工具"
msgstr "高级选项"
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:115
#, fuzzy
msgid "Export debug report"
msgstr "导出调试报告"
@@ -328,23 +330,23 @@ msgstr "复选框列表"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:17
msgid "Highlight"
msgstr "突出显示"
msgstr ""
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:22
msgid "Strikethrough"
msgstr "删除线"
msgstr ""
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:27
msgid "Insert"
msgstr "插入"
msgstr ""
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:33
msgid "Superscript"
msgstr "上标"
msgstr ""
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:39
msgid "Subscript"
msgstr "订阅"
msgstr ""
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js:544
#: packages/app-mobile/components/screens/Note.js:1016
@@ -505,8 +507,9 @@ msgid "Delete line"
msgstr "删除行"
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:92
#, fuzzy
msgid "Duplicate line"
msgstr "复制行"
msgstr "创建副本"
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:96
msgid "Undo"
@@ -697,11 +700,11 @@ msgstr "保存"
msgid ""
"Safe mode is currently active. Note rendering and all plugins are "
"temporarily disabled."
msgstr "安全模式当前已被激活。笔记渲染和所有的插件被临时禁用。"
msgstr ""
#: packages/app-desktop/gui/MainScreen/MainScreen.js:438
msgid "Disable safe mode and restart"
msgstr "禁用安全模式并重启"
msgstr ""
#: packages/app-desktop/gui/MainScreen/MainScreen.js:442
msgid ""
@@ -743,15 +746,15 @@ msgstr "更多信息"
#: packages/app-desktop/gui/MainScreen/MainScreen.js:468
#, javascript-format
msgid "%s (%s) would like to share a notebook with you."
msgstr "%s (%s) 想要分享笔记本给你。"
msgstr ""
#: packages/app-desktop/gui/MainScreen/MainScreen.js:470
msgid "Accept"
msgstr "接受"
msgstr ""
#: packages/app-desktop/gui/MainScreen/MainScreen.js:472
msgid "Reject"
msgstr "拒绝"
msgstr ""
#: packages/app-desktop/gui/MainScreen/MainScreen.js:476
msgid "Some items cannot be synchronised."
@@ -791,11 +794,11 @@ msgstr "新建待办事项"
#: packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js:18
msgid "Toggle note list"
msgstr "切换笔记列表"
msgstr "显示/隐藏笔记列表"
#: packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js:18
msgid "Toggle sidebar"
msgstr "切换边栏"
msgstr "显示/隐藏边栏"
#: packages/app-desktop/gui/MainScreen/commands/editAlarm.js:20
#: packages/app-mobile/components/SelectDateTimeDialog.js:84
@@ -835,8 +838,9 @@ msgid "Toggle editors"
msgstr "切换编辑器"
#: packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js:16
#, fuzzy
msgid "Share notebook..."
msgstr "分享笔记..."
msgstr "分享笔记..."
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js:16
msgid "Change application layout"
@@ -913,7 +917,7 @@ msgstr "令牌已复制到剪贴板!"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:44
msgid "Are you sure you want to renew the authorisation token?"
msgstr "确定要更新授权令牌吗?"
msgstr ""
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:84
msgid "The web clipper service is enabled and set to auto-start."
@@ -992,7 +996,7 @@ msgstr "该授权令牌仅用于允许第三方应用程序访问 Joplin。"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:222
msgid "Renew token"
msgstr "更新令牌"
msgstr ""
#: packages/app-desktop/gui/MenuBar.js:167
#, javascript-format
@@ -1171,7 +1175,7 @@ msgstr "以下笔记已被导入: %s"
#: packages/app-desktop/gui/utils/NoteListUtils.js:44
msgid "Duplicate"
msgstr "复制"
msgstr "创建副本"
#: packages/app-desktop/gui/utils/NoteListUtils.js:49
#, javascript-format
@@ -1464,12 +1468,13 @@ msgid "You do not have any installed plugin."
msgstr "您尚未安装任何插件。"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:232
#, fuzzy
msgid "Could not connect to plugin repository"
msgstr "无法连接到插件库"
msgstr "无法安装插件:%s"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:234
msgid "Try again"
msgstr "重试"
msgstr ""
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:242
msgid "Plugin tools"
@@ -1679,18 +1684,19 @@ msgid_plural "Copy Shareable Links"
msgstr[0] "复制分享链接"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:138
#, fuzzy
msgid "Unshare"
msgstr "取消分享"
msgstr "分享"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:180
msgid ""
"Delete this invitation? The recipient will no longer have access to this "
"shared notebook."
msgstr "删除这个邀请?接受者将无法再访问到这个共享的笔记本。"
msgstr ""
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:194
msgid "Add recipient:"
msgstr "添加接受者:"
msgstr ""
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:197
#: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:28
@@ -1700,41 +1706,45 @@ msgstr "分享"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:206
msgid "Recipient has not yet accepted the invitation"
msgstr "接受者还没有接受邀请"
msgstr ""
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:207
msgid "Recipient has rejected the invitation"
msgstr "接受者拒绝了邀请"
msgstr ""
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:208
msgid "Recipient has accepted the invitation"
msgstr "接受者接受了邀请"
msgstr ""
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:218
msgid "Recipients:"
msgstr "接受者:"
msgstr ""
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:230
#, fuzzy
msgid "Synchronizing..."
msgstr "正在同步..."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:231
#, fuzzy
msgid "Sharing notebook..."
msgstr "分享笔记..."
msgstr "分享笔记..."
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:241
msgid ""
"Unshare this notebook? The recipients will no longer have access to its "
"content."
msgstr "取消分享这个笔记本?接受者将无法再访问到它的内容。"
msgstr ""
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:251
#, fuzzy
msgid "Share Notebook"
msgstr "分享笔记"
msgstr "分享笔记"
#: packages/app-desktop/commands/toggleSafeMode.js:18
#, fuzzy
msgid "Toggle safe mode"
msgstr "切换安全模式"
msgstr "显示/隐藏边栏"
#: packages/app-desktop/commands/toggleExternalEditing.js:18
msgid "Toggle external editing"
@@ -1826,7 +1836,7 @@ msgstr "配置"
#: packages/app-mobile/components/side-menu-content.js:351
msgid "Mobile data - auto-sync disabled"
msgstr "手机数据自动同步被禁用"
msgstr ""
#: packages/app-mobile/components/note-list.js:97
msgid "You currently have no notebooks."
@@ -2339,8 +2349,9 @@ msgid "Joplin Server URL"
msgstr "Joplin 服务器 URL"
#: packages/lib/models/Setting.js:335
#, fuzzy
msgid "Joplin Server email"
msgstr "Joplin Server 邮箱"
msgstr "Joplin 服务器"
#: packages/lib/models/Setting.js:346
msgid "Joplin Server password"
@@ -2582,11 +2593,11 @@ msgid ""
"Used for most text in the markdown editor. If not found, a generic "
"proportional (variable width) font is used."
msgstr ""
"用于 markdown 编辑器中的大多数文本。如果没找到,会使用默认(非等宽)字体。"
#: packages/lib/models/Setting.js:726
#, fuzzy
msgid "Editor monospace font family"
msgstr "编辑器等宽字体族"
msgstr "编辑器字体族"
#: packages/lib/models/Setting.js:727
msgid ""
@@ -2594,8 +2605,6 @@ msgid ""
"tables, checkboxes, code). If not found, a generic monospace (fixed width) "
"font is used."
msgstr ""
"用于那些需要使用固定宽度的字体来清晰地布局文本的场景(例如:表格、多选框和代"
"码块)。如果没有找到,会使用默认(等宽)字体。"
#: packages/lib/models/Setting.js:748
msgid "Custom stylesheet for rendered Markdown"
@@ -2648,7 +2657,7 @@ msgstr "%d 小时"
#: packages/lib/models/Setting.js:817
msgid "Synchronise only over WiFi connection"
msgstr "只通过 WiFi 网络同步数据"
msgstr ""
#: packages/lib/models/Setting.js:824
msgid "Text editor command"
@@ -3725,7 +3734,7 @@ msgstr "将选定文件添加到笔记中。"
msgid ""
"Runs the commands contained in the text file. There should be one command "
"per line."
msgstr "执行文本文件中包含的命令。每个命令占一行。"
msgstr ""
#: packages/app-cli/app/command-version.js:11
msgid "Displays version information"