diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index c226f2ba9..ac602c98c 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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 diff --git a/packages/server/src/middleware/routeHandler.ts b/packages/server/src/middleware/routeHandler.ts index 5238f4b3b..0c278750c 100644 --- a/packages/server/src/middleware/routeHandler.ts +++ b/packages/server/src/middleware/routeHandler.ts @@ -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(); diff --git a/packages/server/src/models/ChangeModel.ts b/packages/server/src/models/ChangeModel.ts index 71bfb5f91..8d29d9e82 100644 --- a/packages/server/src/models/ChangeModel.ts +++ b/packages/server/src/models/ChangeModel.ts @@ -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 { return this.save(change) as Change; } + private async countByUser(userId: string): Promise { + 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 { + 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 { @@ -129,6 +149,7 @@ export default class ChangeModel extends BaseModel { output.push({ type: change.type, + updated_time: change.updated_time, item: item, }); } diff --git a/packages/server/src/models/FileModel.ts b/packages/server/src/models/FileModel.ts index 2fd9248a1..2bbf7905d 100644 --- a/packages/server/src/models/FileModel.ts +++ b/packages/server/src/models/FileModel.ts @@ -98,6 +98,11 @@ export default class FileModel extends BaseModel { return output; } + public async itemDisplayPath(item: File, loadOptions: LoadOptions = {}): Promise { + const path = await this.itemFullPath(item, loadOptions); + return this.removeTrailingColons(path.replace(/root:\//, '')); + } + public async itemFullPath(item: File, loadOptions: LoadOptions = {}): Promise { const segments: string[] = []; while (item) { @@ -347,7 +352,8 @@ export default class FileModel extends BaseModel { public async fileUrl(idOrPath: string, query: any = null): Promise { 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 { diff --git a/packages/server/src/models/utils/pagination.ts b/packages/server/src/models/utils/pagination.ts index 3d5fb7b8c..767e1394e 100644 --- a/packages/server/src/models/utils/pagination.ts +++ b/packages/server/src/models/utils/pagination.ts @@ -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 []; diff --git a/packages/server/src/routes/index/changes.ts b/packages/server/src/routes/index/changes.ts new file mode 100644 index 000000000..cb4384ab0 --- /dev/null +++ b/packages/server/src/routes/index/changes.ts @@ -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; diff --git a/packages/server/src/routes/index/files.ts b/packages/server/src/routes/index/files.ts index a7b9981f7..cfa53ebd2 100644 --- a/packages/server/src/routes/index/files.ts +++ b/packages/server/src/routes/index/files.ts @@ -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(); diff --git a/packages/server/src/routes/routes.ts b/packages/server/src/routes/routes.ts index 7f068f1f6..5b08822fa 100644 --- a/packages/server/src/routes/routes.ts +++ b/packages/server/src/routes/routes.ts @@ -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, }; diff --git a/packages/server/src/views/index/changes.mustache b/packages/server/src/views/index/changes.mustache new file mode 100644 index 000000000..aeac2527e --- /dev/null +++ b/packages/server/src/views/index/changes.mustache @@ -0,0 +1,29 @@ + + + + + + + + + + + {{#paginatedChanges.items}} + + + + + + + {{/paginatedChanges.items}} + +
ItemTypeChangeTimestamp
+ {{#url}} + {{name}} + {{/url}} + {{^url}} + {{name}} + {{/url}} + {{type}}{{changeType}}{{timestamp}}
+ +{{>pagination}} diff --git a/packages/server/src/views/partials/navbar.mustache b/packages/server/src/views/partials/navbar.mustache index 241934c39..5c80a038a 100644 --- a/packages/server/src/views/partials/navbar.mustache +++ b/packages/server/src/views/partials/navbar.mustache @@ -11,6 +11,7 @@ Users {{/global.owner.is_admin}} Files + Log