1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-26 18:58:21 +02:00

Server: Added log page to view latest changes to files

This commit is contained in:
Laurent Cozic 2021-03-20 18:09:55 +01:00
parent abe0013914
commit 874f3010b7
10 changed files with 176 additions and 10 deletions

View File

@ -211,6 +211,13 @@ export enum ChangeType {
Delete = 3,
}
export function changeTypeToString(t: ChangeType): string {
if (t === ChangeType.Create) return 'create';
if (t === ChangeType.Update) return 'update';
if (t === ChangeType.Delete) return 'delete';
throw new Error(`Unkown type: ${t}`);
}
export enum ShareType {
Link = 1, // When a note is shared via a public link
App = 2, // When a note is shared with another user on the same server instance

View File

@ -44,7 +44,7 @@ export default async function(ctx: AppContext) {
});
} else {
ctx.response.status = 200;
ctx.response.body = responseObject;
ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
}
} else {
throw new ErrorNotFound();

View File

@ -1,10 +1,11 @@
import { Change, ChangeType, File, ItemType, Uuid } from '../db';
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors';
import BaseModel from './BaseModel';
import { PaginatedResults } from './utils/pagination';
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
export interface ChangeWithItem {
item: File;
updated_time: number;
type: ChangeType;
}
@ -47,6 +48,25 @@ export default class ChangeModel extends BaseModel<Change> {
return this.save(change) as Change;
}
private async countByUser(userId: string): Promise<number> {
const r: any = await this.db(this.tableName).where('owner_id', userId).count('id', { as: 'total' }).first();
return r.total;
}
public changeUrl(): string {
return `${this.baseUrl}/changes`;
}
public async allWithPagination(pagination: Pagination): Promise<PaginatedChanges> {
const results = await paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('owner_id', '=', this.userId), pagination);
const changeWithItems = await this.loadChangeItems(results.items);
return {
...results,
items: changeWithItems,
page_count: Math.ceil(await this.countByUser(this.userId) / pagination.limit),
};
}
// Note: doesn't currently support checking for changes recursively but this
// is not needed for Joplin synchronisation.
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> {
@ -129,6 +149,7 @@ export default class ChangeModel extends BaseModel<Change> {
output.push({
type: change.type,
updated_time: change.updated_time,
item: item,
});
}

View File

@ -98,6 +98,11 @@ export default class FileModel extends BaseModel<File> {
return output;
}
public async itemDisplayPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
const path = await this.itemFullPath(item, loadOptions);
return this.removeTrailingColons(path.replace(/root:\//, ''));
}
public async itemFullPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
const segments: string[] = [];
while (item) {
@ -347,7 +352,8 @@ export default class FileModel extends BaseModel<File> {
public async fileUrl(idOrPath: string, query: any = null): Promise<string> {
const file: File = await this.pathToFile(idOrPath);
return setQueryParameters(`${this.baseUrl}/files/${await this.itemFullPath(file)}`, query);
const contentSuffix = !file.is_directory ? '/content' : '';
return setQueryParameters(`${this.baseUrl}/files/${await this.itemFullPath(file)}${contentSuffix}`, query);
}
private async pathToFiles(path: string, mustExist: boolean = true): Promise<File[]> {

View File

@ -33,6 +33,7 @@ export interface PaginatedResults {
items: any[];
has_more: boolean;
cursor?: string;
page_count?: number;
}
export const pageMaxSize = 100;
@ -135,6 +136,15 @@ export function paginationToQueryParams(pagination: Pagination): PaginationQuery
return output;
}
export function queryParamsToPagination(query: PaginationQueryParams): Pagination {
const limit = Number(query.limit) || pageMaxSize;
const order: PaginationOrder[] = requestPaginationOrder(query);
const page: number = 'page' in query ? Number(query.page) : 1;
const output: Pagination = { limit, order, page };
validatePagination(output);
return output;
}
export interface PageLink {
page?: number;
isEllipsis?: boolean;
@ -142,6 +152,14 @@ export interface PageLink {
url?: string;
}
export function filterPaginationQueryParams(query: any): PaginationQueryParams {
const baseUrlQuery: PaginationQueryParams = {};
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;
return baseUrlQuery;
}
export function createPaginationLinks(page: number, pageCount: number, urlTemplate: string = null): PageLink[] {
if (!pageCount) return [];

View File

@ -0,0 +1,86 @@
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import { changeTypeToString, ChangeType } from '../../db';
import { createPaginationLinks, filterPaginationQueryParams, queryParamsToPagination } from '../../models/utils/pagination';
import { setQueryParameters } from '../../utils/urlUtils';
import { formatDateTime } from '../../utils/time';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
interface ItemToDisplay {
name: string;
type: string;
changeType: string;
timestamp: string;
url: string;
}
const router = new Router();
router.get('changes', async (_path: SubPath, ctx: AppContext) => {
const changeModel = ctx.models.change({ userId: ctx.owner.id });
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const pagination = queryParamsToPagination(ctx.query);
// {
// "items": [
// {
// "type": 3,
// "item": {
// "id": "QZbQVWTCtr9qpxtEsuWMoQbax8wR1Q75",
// "name": "sync_desktop_bbecbb2d6bf44a16aa14c14f6c51719d.json"
// }
// },
// {
// "type": 1,
// "item": {
// "id": "8ogKqMu58u1FcZ9gaBO1yqPHKzniZSfx",
// "owner_id": "Pg8NSIS3fo7sotSktqb2Rza7EJFcpj3M",
// "name": "ab9e895491844213a43338608deaf573.md",
// "mime_type": "text/markdown",
// "size": 908,
// "is_directory": 0,
// "is_root": 0,
// "parent_id": "5IhOFX314EZOL21p9UUVKZElgjhuUerV",
// "updated_time": 1616235197809,
// "created_time": 1616235197809
// }
// }
// ]
// }
const paginatedChanges = await changeModel.allWithPagination(pagination);
const itemsToDisplay: ItemToDisplay[] = [];
for (const item of paginatedChanges.items) {
itemsToDisplay.push({
name: await fileModel.itemDisplayPath(item.item),
type: item.item.is_directory ? 'd' : 'f',
changeType: changeTypeToString(item.type),
timestamp: formatDateTime(item.updated_time),
url: item.type !== ChangeType.Delete ? await fileModel.fileUrl(item.item.id) : '',
});
}
const paginationLinks = createPaginationLinks(
pagination.page,
paginatedChanges.page_count,
setQueryParameters(
changeModel.changeUrl(), {
...filterPaginationQueryParams(ctx.query),
'page': 'PAGE_NUMBER',
}
)
);
const view: View = defaultView('changes');
view.content.paginatedChanges = { ...paginatedChanges, items: itemsToDisplay };
view.content.paginationLinks = paginationLinks;
view.cssFiles = ['index/changes'];
view.partials.push('pagination');
return view;
});
export default router;

View File

@ -4,7 +4,7 @@ import { AppContext, HttpMethod } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorNotFound } from '../../utils/errors';
import { File } from '../../db';
import { createPaginationLinks, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, requestPaginationOrder, validatePagination } from '../../models/utils/pagination';
import { createPaginationLinks, filterPaginationQueryParams, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, requestPaginationOrder, validatePagination } from '../../models/utils/pagination';
import { setQueryParameters } from '../../utils/urlUtils';
import config from '../../config';
import { formatDateTime } from '../../utils/time';
@ -28,15 +28,11 @@ router.alias(HttpMethod.GET, 'files', 'files/:id');
router.get('files/:id', async (path: SubPath, ctx: AppContext) => {
const dirId = path.id;
const query = ctx.query;
// 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 baseUrlQuery = filterPaginationQueryParams(ctx.query);
const pagination = makeFilePagination(query);
const pagination = makeFilePagination(ctx.query);
const owner = ctx.owner;
const fileModel = ctx.models.file({ userId: owner.id });
const root = await fileModel.userRootFile();

View File

@ -12,6 +12,7 @@ import indexUsers from './index/users';
import indexFiles from './index/files';
import indexNotifications from './index/notifications';
import indexShares from './index/shares';
import indexChanges from './index/changes';
import defaultRoute from './default';
@ -28,6 +29,7 @@ const routes: Routers = {
'files': indexFiles,
'notifications': indexNotifications,
'shares': indexShares,
'changes': indexChanges,
'': defaultRoute,
};

View File

@ -0,0 +1,29 @@
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th class="stretch">Item</th>
<th class="nowrap">Type</th>
<th class="nowrap">Change</th>
<th class="nowrap">Timestamp</th>
</tr>
</thead>
<tbody>
{{#paginatedChanges.items}}
<tr>
<td class="stretch item-{{type}}">
{{#url}}
<a href="{{url}}">{{name}}</a>
{{/url}}
{{^url}}
{{name}}
{{/url}}
</td>
<td class="nowrap">{{type}}</td>
<td class="nowrap">{{changeType}}</td>
<td class="nowrap">{{timestamp}}</td>
</tr>
{{/paginatedChanges.items}}
</tbody>
</table>
{{>pagination}}

View File

@ -11,6 +11,7 @@
<a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
{{/global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/files">Files</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div>
<div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div>