Server: Added log page to view latest changes to files

pull/4741/head
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, 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 { export enum ShareType {
Link = 1, // When a note is shared via a public link 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 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 { } else {
ctx.response.status = 200; ctx.response.status = 200;
ctx.response.body = responseObject; ctx.response.body = [undefined, null].includes(responseObject) ? '' : responseObject;
} }
} else { } else {
throw new ErrorNotFound(); throw new ErrorNotFound();

View File

@ -1,10 +1,11 @@
import { Change, ChangeType, File, ItemType, Uuid } from '../db'; import { Change, ChangeType, File, ItemType, Uuid } from '../db';
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors'; import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
import { PaginatedResults } from './utils/pagination'; import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
export interface ChangeWithItem { export interface ChangeWithItem {
item: File; item: File;
updated_time: number;
type: ChangeType; type: ChangeType;
} }
@ -47,6 +48,25 @@ export default class ChangeModel extends BaseModel<Change> {
return this.save(change) as 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 // Note: doesn't currently support checking for changes recursively but this
// is not needed for Joplin synchronisation. // is not needed for Joplin synchronisation.
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> { public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> {
@ -129,6 +149,7 @@ export default class ChangeModel extends BaseModel<Change> {
output.push({ output.push({
type: change.type, type: change.type,
updated_time: change.updated_time,
item: item, item: item,
}); });
} }

View File

@ -98,6 +98,11 @@ export default class FileModel extends BaseModel<File> {
return output; 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> { public async itemFullPath(item: File, loadOptions: LoadOptions = {}): Promise<string> {
const segments: string[] = []; const segments: string[] = [];
while (item) { while (item) {
@ -347,7 +352,8 @@ export default class FileModel extends BaseModel<File> {
public async fileUrl(idOrPath: string, query: any = null): Promise<string> { public async fileUrl(idOrPath: string, query: any = null): Promise<string> {
const file: File = await this.pathToFile(idOrPath); 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[]> { private async pathToFiles(path: string, mustExist: boolean = true): Promise<File[]> {

View File

@ -33,6 +33,7 @@ export interface PaginatedResults {
items: any[]; items: any[];
has_more: boolean; has_more: boolean;
cursor?: string; cursor?: string;
page_count?: number;
} }
export const pageMaxSize = 100; export const pageMaxSize = 100;
@ -135,6 +136,15 @@ export function paginationToQueryParams(pagination: Pagination): PaginationQuery
return output; 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 { export interface PageLink {
page?: number; page?: number;
isEllipsis?: boolean; isEllipsis?: boolean;
@ -142,6 +152,14 @@ export interface PageLink {
url?: string; 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[] { export function createPaginationLinks(page: number, pageCount: number, urlTemplate: string = null): PageLink[] {
if (!pageCount) return []; 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 { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorNotFound } from '../../utils/errors'; import { ErrorNotFound } from '../../utils/errors';
import { File } from '../../db'; 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 { setQueryParameters } from '../../utils/urlUtils';
import config from '../../config'; import config from '../../config';
import { formatDateTime } from '../../utils/time'; 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) => { router.get('files/:id', async (path: SubPath, ctx: AppContext) => {
const dirId = path.id; const dirId = path.id;
const query = ctx.query;
// Query parameters that should be appended to pagination-related URLs // Query parameters that should be appended to pagination-related URLs
const baseUrlQuery: any = {}; const baseUrlQuery = filterPaginationQueryParams(ctx.query);
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 pagination = makeFilePagination(ctx.query);
const owner = ctx.owner; const owner = ctx.owner;
const fileModel = ctx.models.file({ userId: owner.id }); const fileModel = ctx.models.file({ userId: owner.id });
const root = await fileModel.userRootFile(); const root = await fileModel.userRootFile();

View File

@ -12,6 +12,7 @@ import indexUsers from './index/users';
import indexFiles from './index/files'; import indexFiles from './index/files';
import indexNotifications from './index/notifications'; import indexNotifications from './index/notifications';
import indexShares from './index/shares'; import indexShares from './index/shares';
import indexChanges from './index/changes';
import defaultRoute from './default'; import defaultRoute from './default';
@ -28,6 +29,7 @@ const routes: Routers = {
'files': indexFiles, 'files': indexFiles,
'notifications': indexNotifications, 'notifications': indexNotifications,
'shares': indexShares, 'shares': indexShares,
'changes': indexChanges,
'': defaultRoute, '': 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> <a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
{{/global.owner.is_admin}} {{/global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/files">Files</a> <a class="navbar-item" href="{{{global.baseUrl}}}/files">Files</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div> <div class="navbar-item">{{global.owner.email}}</div>