feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,66 @@
|
||||
import { ModelType } from '../../../BaseModel';
|
||||
import { ModelFeedPage } from '../../../models/utils/paginatedFeed';
|
||||
import { PaginationOrderDir } from '../../../models/utils/types';
|
||||
import { Request } from '../Api';
|
||||
import requestPaginationOptions from '../utils/requestPaginationOptions';
|
||||
import requestFields from './requestFields';
|
||||
|
||||
export interface Options {
|
||||
sort?: boolean;
|
||||
}
|
||||
|
||||
// Note that this is inefficient pagination as it requires all the items to
|
||||
// have been loaded first, even if not all of them are returned.
|
||||
//
|
||||
// It's however convenient for smaller lists as it reduces the need for
|
||||
// building complex SQL queries.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Callers pass entity types (NoteEntity, FolderEntity, etc.) which lack index signatures
|
||||
export default function(itemType: ModelType, items: any[], request: Request, options: Options = null): ModelFeedPage {
|
||||
options = {
|
||||
sort: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
const fields = requestFields(request, itemType);
|
||||
const pagination = requestPaginationOptions(request);
|
||||
const startIndex = (pagination.page - 1) * pagination.limit;
|
||||
const itemCount = Math.min(items.length - startIndex, pagination.limit);
|
||||
const hasMore = itemCount >= pagination.limit;
|
||||
|
||||
const sortBy = pagination.order[0].by;
|
||||
const sortDir = pagination.order[0].dir;
|
||||
const caseInsensitive = pagination.order[0].caseInsensitive;
|
||||
const sortedItems = items.slice().map(item => {
|
||||
if (!fields.length) return item;
|
||||
const newItem: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(item)) {
|
||||
if (!fields.includes(k)) continue;
|
||||
newItem[k] = item[k];
|
||||
}
|
||||
return newItem;
|
||||
});
|
||||
|
||||
if (options.sort) {
|
||||
sortedItems.sort((a, b) => {
|
||||
let v1: unknown = a && (sortBy in a) ? a[sortBy] : '';
|
||||
let v2: unknown = b && (sortBy in b) ? b[sortBy] : '';
|
||||
if (caseInsensitive && typeof v1 === 'string') v1 = v1.toLowerCase();
|
||||
if (caseInsensitive && typeof v2 === 'string') v2 = v2.toLowerCase();
|
||||
|
||||
if (sortDir === PaginationOrderDir.ASC) {
|
||||
if (v1 < v2) return -1;
|
||||
if (v2 > v1) return +1;
|
||||
} else {
|
||||
if (v1 > v2) return -1;
|
||||
if (v2 < v1) return +1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items: sortedItems.slice(startIndex, startIndex + itemCount),
|
||||
has_more: hasMore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Request, RequestMethod } from '../Api';
|
||||
import defaultSaveOptions from './defaultSaveOptions';
|
||||
import { ErrorMethodNotAllowed, ErrorNotFound } from './errors';
|
||||
import paginatedResults from './paginatedResults';
|
||||
import readonlyProperties from './readonlyProperties';
|
||||
import requestFields from './requestFields';
|
||||
import BaseItem from '../../../models/BaseItem';
|
||||
import { LoadOptions } from '../../../models/utils/types';
|
||||
import { WhereQuery } from '../../../models/utils/paginatedFeed';
|
||||
|
||||
export default async function(modelType: number, request: Request, id: string = null, link: string = null, defaultFields: string[] = null, whereQuery: WhereQuery = null) {
|
||||
if (link) throw new ErrorNotFound(); // Default action doesn't support links at all for now
|
||||
|
||||
const ModelClass = BaseItem.getClassByItemType(modelType);
|
||||
|
||||
const getOneModel = async (options: LoadOptions = null) => {
|
||||
const model = await ModelClass.load(id, options || {});
|
||||
if (!model) throw new ErrorNotFound();
|
||||
return model;
|
||||
};
|
||||
|
||||
if (request.method === 'GET') {
|
||||
if (id) {
|
||||
return getOneModel({
|
||||
fields: requestFields(request, modelType, defaultFields),
|
||||
});
|
||||
} else {
|
||||
return paginatedResults(modelType, request, whereQuery, defaultFields);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'PUT' && id) {
|
||||
const model = await getOneModel();
|
||||
const newModel = { ...model, ...request.bodyJson(readonlyProperties('PUT')) };
|
||||
return ModelClass.save(newModel, { userSideValidation: true });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE' && id) {
|
||||
const model = await getOneModel();
|
||||
await ModelClass.delete(model.id, { source: 'API: DELETE method' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === RequestMethod.POST) {
|
||||
const props = readonlyProperties('POST');
|
||||
const idIdx = props.indexOf('id');
|
||||
if (idIdx >= 0) props.splice(idIdx, 1);
|
||||
const model = request.bodyJson(props);
|
||||
return ModelClass.save(model, defaultSaveOptions('POST', model.id));
|
||||
}
|
||||
|
||||
throw new ErrorMethodNotAllowed();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Request } from '../Api';
|
||||
import { LoadOptions } from '../../../models/utils/types';
|
||||
import requestFields from './requestFields';
|
||||
|
||||
export default function(request: Request, modelType: number): LoadOptions {
|
||||
const options: LoadOptions = {};
|
||||
const fields = requestFields(request, modelType);
|
||||
if (fields.length) options.fields = fields;
|
||||
return options;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface DefaultSaveOptions {
|
||||
userSideValidation: boolean;
|
||||
isNew?: boolean;
|
||||
autoTimestamp?: boolean;
|
||||
}
|
||||
|
||||
export default function(requestMethod: string, modelId: string = null): DefaultSaveOptions {
|
||||
const options: DefaultSaveOptions = { userSideValidation: true };
|
||||
if (requestMethod === 'POST' && modelId) options.isNew = true;
|
||||
return options;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
class ApiError extends Error {
|
||||
private httpCode_: number;
|
||||
|
||||
public constructor(message: string, httpCode = 400) {
|
||||
super(message);
|
||||
this.httpCode_ = httpCode;
|
||||
}
|
||||
|
||||
public get httpCode() {
|
||||
return this.httpCode_;
|
||||
}
|
||||
}
|
||||
|
||||
export class ErrorMethodNotAllowed extends ApiError {
|
||||
public constructor(message = 'Method Not Allowed') {
|
||||
super(message, 405);
|
||||
}
|
||||
}
|
||||
export class ErrorNotFound extends ApiError {
|
||||
public constructor(message = 'Not Found') {
|
||||
super(message, 404);
|
||||
}
|
||||
}
|
||||
export class ErrorForbidden extends ApiError {
|
||||
public constructor(message = 'Forbidden') {
|
||||
super(message, 403);
|
||||
}
|
||||
}
|
||||
export class ErrorBadRequest extends ApiError {
|
||||
public constructor(message = 'Bad Request') {
|
||||
super(message, 400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Request } from '../Api';
|
||||
import requestFields from './requestFields';
|
||||
import BaseModel from '../../../BaseModel';
|
||||
import requestPaginationOptions from './requestPaginationOptions';
|
||||
import paginatedFeed, { WhereQuery, ModelFeedPage } from '../../../models/utils/paginatedFeed';
|
||||
import BaseItem from '../../../models/BaseItem';
|
||||
|
||||
export default async function(modelType: number, request: Request, whereQuery: WhereQuery = null, defaultFields: string[] = null): Promise<ModelFeedPage> {
|
||||
const ModelClass = BaseItem.getClassByItemType(modelType);
|
||||
const fields = requestFields(request, modelType, defaultFields);
|
||||
const pagination = requestPaginationOptions(request);
|
||||
return paginatedFeed(BaseModel.db(), ModelClass.tableName(), pagination, whereQuery, fields);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function readonlyProperties(requestMethod: string) {
|
||||
const output = ['created_time', 'updated_time', 'encryption_blob_encrypted', 'encryption_applied', 'encryption_cipher_text'];
|
||||
if (requestMethod !== 'POST') output.splice(0, 0, 'id');
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Request } from '../Api';
|
||||
import BaseItem from '../../../models/BaseItem';
|
||||
|
||||
function defaultFieldsByModelType(modelType: number): string[] {
|
||||
const ModelClass = BaseItem.getClassByItemType(modelType);
|
||||
const possibleFields = ['id', 'parent_id', 'title', 'deleted_time'];
|
||||
const output = [];
|
||||
for (const f of possibleFields) {
|
||||
if (ModelClass.hasField(f)) output.push(f);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export default function(request: Request, modelType: number, defaultFields: string[] = null) {
|
||||
const getDefaults = () => {
|
||||
if (defaultFields) return defaultFields;
|
||||
return defaultFieldsByModelType(modelType);
|
||||
};
|
||||
|
||||
const query = request.query;
|
||||
if (!query || !query.fields) return getDefaults();
|
||||
if (Array.isArray(query.fields)) return query.fields.slice();
|
||||
const fields = query.fields
|
||||
.split(',')
|
||||
.map((f: string) => f.trim())
|
||||
.filter((f: string) => !!f);
|
||||
return fields.length ? fields : getDefaults();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Pagination, PaginationOrder, PaginationOrderDir } from '../../../models/utils/types';
|
||||
import { Request } from '../Api';
|
||||
import { ErrorBadRequest } from './errors';
|
||||
|
||||
function requestPaginationOrder(request: Request): PaginationOrder[] {
|
||||
const orderBy: string = request.query.order_by ? request.query.order_by : 'updated_time';
|
||||
const orderDir: PaginationOrderDir = request.query.order_dir ? request.query.order_dir : PaginationOrderDir.ASC;
|
||||
|
||||
return [{
|
||||
by: orderBy,
|
||||
dir: orderDir,
|
||||
caseInsensitive: true,
|
||||
}];
|
||||
}
|
||||
|
||||
export default function(request: Request): Pagination {
|
||||
const limit = request.query.limit ? request.query.limit : 100;
|
||||
if (limit < 0 || limit > 100) throw new ErrorBadRequest(`Limit out of bond: ${limit}`);
|
||||
const order: PaginationOrder[] = requestPaginationOrder(request);
|
||||
const page: number = request.query.page || 1;
|
||||
|
||||
return { limit, order, page };
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户