1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

3 Commits

Author SHA1 Message Date
Laurent Cozic
8801e82cab pagination 2020-12-29 14:35:51 +00:00
Laurent Cozic
c4757d6c60 pagination 2020-12-29 00:13:04 +00:00
Laurent Cozic
af6c79b844 file manager 2020-12-28 17:24:06 +00:00
22 changed files with 343 additions and 74 deletions

View File

@@ -1481,6 +1481,9 @@ packages/server/src/models/ChangeModel.test.js.map
packages/server/src/models/FileModel.d.ts
packages/server/src/models/FileModel.js
packages/server/src/models/FileModel.js.map
packages/server/src/models/FileModel.test.d.ts
packages/server/src/models/FileModel.test.js
packages/server/src/models/FileModel.test.js.map
packages/server/src/models/PermissionModel.d.ts
packages/server/src/models/PermissionModel.js
packages/server/src/models/PermissionModel.js.map

3
.gitignore vendored
View File

@@ -1470,6 +1470,9 @@ packages/server/src/models/ChangeModel.test.js.map
packages/server/src/models/FileModel.d.ts
packages/server/src/models/FileModel.js
packages/server/src/models/FileModel.js.map
packages/server/src/models/FileModel.test.d.ts
packages/server/src/models/FileModel.test.js
packages/server/src/models/FileModel.test.js.map
packages/server/src/models/PermissionModel.d.ts
packages/server/src/models/PermissionModel.js
packages/server/src/models/PermissionModel.js.map

View File

@@ -429,6 +429,11 @@
"minimist": "^1.2.0"
}
},
"@fortawesome/fontawesome-free": {
"version": "5.15.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.1.tgz",
"integrity": "sha512-OEdH7SyC1suTdhBGW91/zBfR6qaIhThbcN8PUXtXilY4GYnSBbVqOntdHbC1vXwsDnX0Qix2m2+DSU1J51ybOQ=="
},
"@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -1552,6 +1557,14 @@
"dev": true,
"requires": {
"sprintf-js": "~1.0.2"
},
"dependencies": {
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true
}
}
},
"arr-diff": {
@@ -2439,6 +2452,11 @@
"whatwg-url": "^8.0.0"
}
},
"dayjs": {
"version": "1.9.8",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.9.8.tgz",
"integrity": "sha512-F42qBtJRa30FKF7XDnOQyNUTsaxDkuaZRj/i7BejSHC34LlLfPoIU4aeopvWfM+m1dJ6/DHKAWLg2ur+pLgq1w=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -7160,10 +7178,9 @@
}
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
},
"sqlite3": {
"version": "4.1.0",

View File

@@ -12,10 +12,12 @@
"watch": "tsc --watch --project tsconfig.json"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.1",
"@joplin/lib": "^1.0.9",
"bcryptjs": "^2.4.3",
"bulma": "^0.9.1",
"bulma-prefers-dark": "^0.1.0-beta.0",
"dayjs": "^1.9.8",
"formidable": "^1.2.2",
"fs-extra": "^8.1.0",
"html-entities": "^1.3.1",

View File

@@ -25,3 +25,11 @@ input.form-control {
.main {
padding: 0 3rem;
}
table.table .nowrap {
white-space: nowrap;
}
table.table .stretch {
width: 100%;
}

View File

@@ -71,11 +71,11 @@ app.use(async (ctx: Koa.Context) => {
}
} catch (error) {
if (error.httpCode >= 400 && error.httpCode < 500) {
appLogger().error(error.httpCode + ': ' + `${ctx.request.method} ${ctx.path}` + ' : ' + error.message);
appLogger().error(`${error.httpCode}: ` + `${ctx.request.method} ${ctx.path}` + ` : ${error.message}`);
} else {
appLogger().error(error);
}
ctx.response.status = error.httpCode ? error.httpCode : 500;
const responseFormat = routeResponseFormat(match, ctx.path);
@@ -87,6 +87,7 @@ app.use(async (ctx: Koa.Context) => {
path: 'index/error',
content: {
error,
stack: env === 'dev' ? error.stack : '',
},
};
ctx.response.body = await mustacheService.renderView(view);
@@ -111,8 +112,8 @@ async function main() {
const globalLogger = new Logger();
// globalLogger.addTarget(TargetType.File, { path: `${config().logDir}/app.txt` });
globalLogger.addTarget(TargetType.Console, {
format: '%(date_time)s: [%(level)s] %(prefix)s: %(message)s',
formatInfo: '%(date_time)s: %(prefix)s: %(message)s',
format: '%(date_time)s: [%(level)s] %(prefix)s: %(message)s',
formatInfo: '%(date_time)s: %(prefix)s: %(message)s',
});
Logger.initializeGlobalLogger(globalLogger);

View File

@@ -3,20 +3,20 @@ import configBase from './config-base';
const config: Config = {
...configBase,
// database: {
// name: 'dev',
// client: 'sqlite3',
// asyncStackTraces: true,
// },
database: {
client: 'pg',
name: 'joplin',
user: 'joplin',
host: 'localhost',
port: 5432,
password: 'joplin',
name: 'dev',
client: 'sqlite3',
asyncStackTraces: true,
},
// database: {
// client: 'pg',
// name: 'joplin',
// user: 'joplin',
// host: 'localhost',
// port: 5432,
// password: 'joplin',
// asyncStackTraces: true,
// },
};
export default config;

View File

@@ -1,44 +1,85 @@
import BaseController from '../BaseController';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { Pagination } from '../../models/utils/pagination';
import { Pagination, pageMaxSize, PaginationOrder, requestPaginationOrder, PaginationOrderDir, validatePagination, createPaginationLinks } from '../../models/utils/pagination';
import { File } from '../../db';
import { baseUrl } from '../../config';
import { formatDateTime } from '../../utils/time';
import { setQueryParameters } from '../../utils/urlUtils';
export function makeFilePagination(query: any): Pagination {
const limit = Number(query.limit) || pageMaxSize;
const order: PaginationOrder[] = requestPaginationOrder(query, 'name', PaginationOrderDir.ASC);
order.splice(0, 0, { by: 'is_directory', dir: PaginationOrderDir.DESC });
const page: number = 'page' in query ? Number(query.page) : 1;
const output: Pagination = { limit, order, page };
validatePagination(output);
return output;
}
export default class FileController extends BaseController {
public async getIndex(sessionId: string, dirId: string, pagination: Pagination): Promise<View> {
public async getIndex(sessionId: string, dirId: string, query: any): Promise<View> {
// Query parameters that should be appended to pagination-related URLs
const baseUrlQuery: any = {};
if (query.limit) baseUrlQuery.limit = query.limit;
if (query.order_by) baseUrlQuery.order_by = query.order_by;
if (query.order_dir) baseUrlQuery.order_dir = query.order_dir;
const pagination = makeFilePagination(query);
const owner = await this.initSession(sessionId);
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
const parent: File = dirId ? await fileModel.entityFromItemId(dirId) : await fileModel.userRootFile();
const root = await fileModel.userRootFile();
const parentTemp: File = dirId ? await fileModel.entityFromItemId(dirId) : root;
const parent: File = await fileModel.load(parentTemp.id);
const paginatedFiles = await fileModel.childrens(parent.id, pagination);
const pageCount = Math.ceil((await fileModel.childrenCount(parent.id)) / pagination.limit);
const parentBaseUrl = `${baseUrl()}/files/${await fileModel.itemFullPath(parent)}`;
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
async function fileToViewItem(file: File): Promise<any> {
const filePath = await fileModel.itemFullPath(file);
let url = `${baseUrl()}/files/${filePath}`;
if (!file.is_directory) {
url += '/content';
} else {
url = setQueryParameters(url, baseUrlQuery);
}
return {
name: file.name,
url,
type: file.is_directory ? 'directory' : 'file',
icon: file.is_directory ? 'far fa-folder' : 'far fa-file',
timestamp: formatDateTime(file.updated_time),
mime: !file.is_directory ? (file.mime_type || 'binary') : '',
};
}
const files: any[] = [];
if (parent.id !== root.id) {
const p = await fileModel.load(parent.parent_id);
files.push({
...await fileToViewItem(p),
icon: 'fas fa-arrow-left',
name: '..',
});
}
for (const file of paginatedFiles.items) {
files.push(await fileToViewItem(file));
}
const view: View = defaultView('files', owner);
view.content.paginatedFiles = paginatedFiles;
view.content.paginatedFiles = { ...paginatedFiles, items: files };
view.content.paginationLinks = paginationLinks;
view.cssFiles = ['index/files'];
view.partials.push('pagination');
return view;
}
// public async getOne(sessionId: string, isNew: boolean, userIdOrString: string | User = null, error: any = null): Promise<View> {
// const owner = await this.initSession(sessionId);
// const userModel = this.models.user({ userId: owner.id });
// let user: User = {};
// if (typeof userIdOrString === 'string') {
// user = await userModel.load(userIdOrString as string);
// } else {
// user = userIdOrString as User;
// }
// const view: View = defaultView('user', owner);
// view.content.user = user;
// view.content.isNew = isNew;
// view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
// view.content.error = error;
// view.content.postUrl = `${baseUrl()}/users${isNew ? '/new' : `/${user.id}`}`;
// view.partials.push('errorBanner');
// return view;
// }
}

View File

@@ -332,6 +332,13 @@ export default class FileModel extends BaseModel {
return super.save(file, options);
}
public async childrenCount(id: string): Promise<number> {
const parent = await this.load(id);
await this.checkCanReadPermissions(parent);
const r = await this.db(this.tableName).select('id').where('parent_id', id).count('id', { as: 'total' });
return r.length && r[0].total ? r[0].total : 0;
}
public async childrens(id: string, pagination: Pagination): Promise<PaginatedFiles> {
const parent = await this.load(id);
await this.checkCanReadPermissions(parent);

View File

@@ -1,5 +1,5 @@
import { expectThrow } from '../../utils/testUtils';
import { defaultPagination, Pagination, requestPagination } from './pagination';
import { defaultPagination, Pagination, createPaginationLinks, requestPagination } from './pagination';
describe('pagination', function() {
@@ -69,4 +69,56 @@ describe('pagination', function() {
await expectThrow(async () => requestPagination({ page: 0 }));
});
test('should create page link logic', async function() {
expect(createPaginationLinks(1, 5)).toEqual([
{ page: 1, isCurrent: true },
{ page: 2 },
{ page: 3 },
{ page: 4 },
{ page: 5 },
]);
expect(createPaginationLinks(3, 5)).toEqual([
{ page: 1 },
{ page: 2 },
{ page: 3, isCurrent: true },
{ page: 4 },
{ page: 5 },
]);
expect(createPaginationLinks(1, 10)).toEqual([
{ page: 1, isCurrent: true },
{ page: 2 },
{ page: 3 },
{ page: 4 },
{ page: 5 },
{ isEllipsis: true },
{ page: 9 },
{ page: 10 },
]);
expect(createPaginationLinks(10, 20)).toEqual([
{ page: 1 },
{ page: 2 },
{ isEllipsis: true },
{ page: 8 },
{ page: 9 },
{ page: 10, isCurrent: true },
{ page: 11 },
{ page: 12 },
{ isEllipsis: true },
{ page: 19 },
{ page: 20 },
]);
expect(createPaginationLinks(20, 20)).toEqual([
{ page: 1 },
{ page: 2 },
{ isEllipsis: true },
{ page: 18 },
{ page: 19 },
{ page: 20, isCurrent: true },
]);
});
});

View File

@@ -26,17 +26,17 @@ export interface PaginatedResults {
cursor?: string;
}
const pageMaxSize = 1000;
const defaultOrderField = 'updated_time';
const defaultOrderDir = PaginationOrderDir.DESC;
export const pageMaxSize = 1000;
const defaultOrderField_ = 'updated_time';
const defaultOrderDir_ = PaginationOrderDir.DESC;
export function defaultPagination(): Pagination {
return {
limit: pageMaxSize,
order: [
{
by: defaultOrderField,
dir: defaultOrderDir,
by: defaultOrderField_,
dir: defaultOrderDir_,
},
],
page: 1,
@@ -47,7 +47,10 @@ function dbOffset(pagination: Pagination): number {
return pagination.limit * (pagination.page - 1);
}
function requestPaginationOrder(query: any): PaginationOrder[] {
export function requestPaginationOrder(query: any, defaultOrderField: string = null, defaultOrderDir: PaginationOrderDir = null): PaginationOrder[] {
if (defaultOrderField === null) defaultOrderField = defaultOrderField_;
if (defaultOrderDir === null) defaultOrderDir = defaultOrderDir_;
const orderBy: string = 'order_by' in query ? query.order_by : defaultOrderField;
const orderDir: PaginationOrderDir = 'order_dir' in query ? query.order_dir : defaultOrderDir;
@@ -59,7 +62,7 @@ function requestPaginationOrder(query: any): PaginationOrder[] {
}];
}
function validatePagination(p: Pagination): Pagination {
export function validatePagination(p: Pagination): Pagination {
if (p.limit < 0 || p.limit > pageMaxSize) throw new ErrorBadRequest(`Limit out of bond: ${p.limit}`);
if (p.page <= 0) throw new ErrorBadRequest(`Invalid page number: ${p.page}`);
@@ -104,11 +107,74 @@ export function requestChangePagination(query: any): ChangePagination {
return output;
}
export interface PageLink {
page?: number;
isEllipsis?: boolean;
isCurrent?: boolean;
url?: string;
}
export function createPaginationLinks(page: number, pageCount: number, urlTemplate: string = null): PageLink[] {
if (!pageCount) return [];
let output: PageLink[] = [];
const firstPage: number = Math.max(page - 2, 1);
for (let p = firstPage; p <= firstPage + 4; p++) {
if (p > pageCount) break;
output.push({ page: p });
}
const firstPages: PageLink[] = [];
for (let p = 1; p <= 2; p++) {
if (output.find(o => o.page === p) || p > pageCount) continue;
firstPages.push({ page: p });
}
if (firstPages.length && (output[0].page - firstPages[firstPages.length - 1].page) > 1) {
firstPages.push({ isEllipsis: true });
}
output = firstPages.concat(output);
const lastPages: PageLink[] = [];
for (let p = pageCount - 1; p <= pageCount; p++) {
if (output.find(o => o.page === p) || p > pageCount || p < 1) continue;
lastPages.push({ page: p });
}
if (lastPages.length && (lastPages[0].page - output[output.length - 1].page) > 1) {
output.push({ isEllipsis: true });
}
output = output.concat(lastPages);
output = output.map(o => {
return o.page === page ? { ...o, isCurrent: true } : o;
});
if (urlTemplate) {
output = output.map(o => {
if (o.isEllipsis) return o;
return { ...o, url: urlTemplate.replace(/PAGE_NUMBER/, o.page.toString()) };
});
}
return output;
}
export async function paginateDbQuery(query: Knex.QueryBuilder, pagination: Pagination): Promise<PaginatedResults> {
pagination = processCursor(pagination);
const orderSql: any[] = pagination.order.map(o => {
return {
column: o.by,
order: o.dir,
};
});
const items = await query
.orderBy(pagination.order[0].by, pagination.order[0].dir)
.orderBy(orderSql)
.offset(dbOffset(pagination))
.limit(pagination.limit);

View File

@@ -1,7 +1,7 @@
import { ErrorNotFound, ErrorMethodNotAllowed, ErrorBadRequest } from '../../utils/errors';
import { File } from '../../db';
import { bodyFields, formParse, headerSessionId } from '../../utils/requestUtils';
import { SubPath, Route, ResponseType, Response } from '../../utils/routeUtils';
import { SubPath, Route, respondWithFileContent } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import * as fs from 'fs-extra';
import { requestChangePagination, requestPagination } from '../../models/utils/pagination';
@@ -31,12 +31,8 @@ const route: Route = {
if (path.link === 'content') {
if (ctx.method === 'GET') {
const koaResponse = ctx.response;
const file: File = await fileController.getFileContent(headerSessionId(ctx.headers), path.id);
koaResponse.body = file.content;
koaResponse.set('Content-Type', file.mime_type);
koaResponse.set('Content-Length', file.size.toString());
return new Response(ResponseType.KoaResponse, koaResponse);
return respondWithFileContent(ctx.response, file);
}
if (ctx.method === 'PUT') {

View File

@@ -13,14 +13,17 @@ interface PathToFileMap {
}
// Most static assets should be in /public, but for those that are not, for
// example if they are in node_modules, use the map below
// example if they are in node_modules, use the map below.
const pathToFileMap: PathToFileMap = {
'css/bulma.min.css': 'node_modules/bulma/css/bulma.min.css',
'css/bulma-prefers-dark.min.css': 'node_modules/bulma-prefers-dark/css/bulma-prefers-dark.min.css',
'css/fontawesome/css/all.min.css': 'node_modules/@fortawesome/fontawesome-free/css/all.min.css',
};
async function findLocalFile(path: string): Promise<string> {
if (path in pathToFileMap) return pathToFileMap[path];
// For now a bit of a hack to load FontAwesome fonts.
if (path.indexOf('css/fontawesome/webfonts/fa-') === 0) return `node_modules/@fortawesome/fontawesome-free/${path.substr(16)}`;
let localPath = normalize(path);
if (localPath.indexOf('..') >= 0) throw new ErrorNotFound(`Cannot resolve path: ${path}`);

View File

@@ -1,8 +1,8 @@
import { SubPath, Route } from '../../utils/routeUtils';
import { SubPath, Route, respondWithFileContent } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { contextSessionId } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed } from '../../utils/errors';
import { requestPagination } from '../../models/utils/pagination';
import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors';
import { File } from '../../db';
const route: Route = {
@@ -10,7 +10,14 @@ const route: Route = {
const sessionId = contextSessionId(ctx);
if (ctx.method === 'GET') {
return ctx.controllers.indexFiles().getIndex(sessionId, path.id, requestPagination(ctx.query));
if (!path.link) {
return ctx.controllers.indexFiles().getIndex(sessionId, path.id, ctx.query);
} else if (path.link === 'content') {
const file: File = await ctx.controllers.apiFile().getFileContent(sessionId, path.id);
return respondWithFileContent(ctx.response, file);
}
throw new ErrorNotFound();
}
throw new ErrorMethodNotAllowed();

View File

@@ -1,4 +1,4 @@
import { ItemAddressingType } from '../db';
import { File, ItemAddressingType } from '../db';
import { ErrorBadRequest } from './errors';
import { AppContext } from './types';
@@ -145,7 +145,7 @@ export function parseSubPath(p: string): SubPath {
return output;
}
export function routeResponseFormat(match: MatchedRoute, rawPath:string): RouteResponseFormat {
export function routeResponseFormat(match: MatchedRoute, rawPath: string): RouteResponseFormat {
if (match && match.route.responseFormat) return match.route.responseFormat;
let path = rawPath;
@@ -190,3 +190,10 @@ export function findMatchingRoute(path: string, routes: Routes): MatchedRoute {
throw new Error('Unreachable');
}
export function respondWithFileContent(koaResponse: any, file: File): Response {
koaResponse.body = file.content;
koaResponse.set('Content-Type', file.mime_type);
koaResponse.set('Content-Length', file.size.toString());
return new Response(ResponseType.KoaResponse, koaResponse);
}

View File

@@ -1,4 +1,4 @@
/* eslint-disable import/prefer-default-export */
import dayjs = require('dayjs');
export function msleep(ms: number) {
return new Promise((resolve: Function) => {
@@ -7,3 +7,7 @@ export function msleep(ms: number) {
}, ms);
});
}
export function formatDateTime(ms: number): string {
return dayjs(ms).format('D MMM YY HH:mm:ss');
}

View File

@@ -0,0 +1,13 @@
/* eslint-disable import/prefer-default-export */
import { URL } from 'url';
export function setQueryParameters(url: string, query: any): string {
const u = new URL(url);
for (const k of Object.keys(query)) {
u.searchParams.set(k, query[k]);
}
return u.toString();
}

View File

@@ -2,6 +2,9 @@
<div class="container">
<div class="notification is-danger">
{{error.message}}
{{#stack}}
<pre>{{.}}</pre>
{{/stack}}
</div>
<p><a href="{{{global.baseUrl}}}/login">Back to the login page</a></p>
</div>

View File

@@ -1,5 +1,22 @@
<div>
{{#paginatedFiles.items}}
<div>{{name}}</div>
{{/paginatedFiles.items}}
</div>
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th class="stretch">Name</th>
<th class="nowrap">Mime</th>
<th class="nowrap">Timestamp</th>
</tr>
</thead>
<tbody>
{{#paginatedFiles.items}}
<tr>
<td class="stretch item-{{type}}">
<a href="{{url}}"><span class="icon"><i class="{{icon}}"></i></span>{{name}}</a>
</td>
<td class="nowrap">{{mime}}</td>
<td class="nowrap">{{timestamp}}</td>
</tr>
{{/paginatedFiles.items}}
</tbody>
</table>
{{>pagination}}

View File

@@ -5,6 +5,7 @@
<link rel="stylesheet" href="{{{baseUrl}}}/css/bulma.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="{{{baseUrl}}}/css/bulma-prefers-dark.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="{{{baseUrl}}}/css/main.css" crossorigin="anonymous">
<link rel="stylesheet" href="{{{baseUrl}}}/css/fontawesome/css/all.min.css" crossorigin="anonymous">
<script src="{{{baseUrl}}}/js/main.js"></script>
{{#cssFiles}}
<link rel="stylesheet" href="{{{.}}}" crossorigin="anonymous">

View File

@@ -0,0 +1,18 @@
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<a class="pagination-previous">Previous</a>
<a class="pagination-next">Next page</a>
<ul class="pagination-list">
{{#paginationLinks}}
{{#isEllipsis}}
<li>
<span class="pagination-ellipsis">&hellip;</span>
</li>
{{/isEllipsis}}
{{^isEllipsis}}
<li>
<a class="pagination-link {{#isCurrent}}is-current{{/isCurrent}}" aria-label="Goto page {{page}}" href="{{url}}">{{page}}</a>
</li>
{{/isEllipsis}}
{{/paginationLinks}}
</ul>
</nav>