2021-06-18 18:17:25 +02:00
|
|
|
import { MultiPutItem } from './file-api';
|
2021-05-17 18:55:39 +02:00
|
|
|
import JoplinError from './JoplinError';
|
2021-01-29 20:45:11 +02:00
|
|
|
import JoplinServerApi from './JoplinServerApi';
|
2021-05-13 18:57:37 +02:00
|
|
|
import { trimSlashes } from './path-utils';
|
2021-11-03 14:26:26 +02:00
|
|
|
import { Lock, LockClientType, LockType } from './services/synchronizer/LockHandler';
|
2020-12-28 13:48:47 +02:00
|
|
|
|
2021-02-01 12:48:37 +02:00
|
|
|
// All input paths should be in the format: "path/to/file". This is converted to
|
|
|
|
// "root:/path/to/file:" when doing the API call.
|
2020-12-28 13:48:47 +02:00
|
|
|
|
|
|
|
export default class FileApiDriverJoplinServer {
|
|
|
|
|
|
|
|
private api_: JoplinServerApi;
|
|
|
|
|
|
|
|
public constructor(api: JoplinServerApi) {
|
|
|
|
this.api_ = api;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async initialize(basePath: string) {
|
2021-02-01 12:48:37 +02:00
|
|
|
const pieces = trimSlashes(basePath).split('/');
|
2020-12-28 13:48:47 +02:00
|
|
|
if (!pieces.length) return;
|
|
|
|
|
2021-02-01 12:48:37 +02:00
|
|
|
const parent: string[] = [];
|
2020-12-28 13:48:47 +02:00
|
|
|
|
2021-02-01 12:48:37 +02:00
|
|
|
for (let i = 0; i < pieces.length; i++) {
|
|
|
|
const p = pieces[i];
|
|
|
|
const subPath = parent.concat(p).join('/');
|
|
|
|
parent.push(p);
|
2020-12-28 13:48:47 +02:00
|
|
|
await this.mkdir(subPath);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public api() {
|
|
|
|
return this.api_;
|
|
|
|
}
|
|
|
|
|
2021-06-18 18:17:25 +02:00
|
|
|
public get supportsMultiPut() {
|
2021-06-26 11:19:48 +02:00
|
|
|
return true;
|
2021-06-18 18:17:25 +02:00
|
|
|
}
|
|
|
|
|
2021-06-19 11:34:44 +02:00
|
|
|
public get supportsAccurateTimestamp() {
|
2021-06-26 11:19:48 +02:00
|
|
|
return true;
|
2021-06-19 11:34:44 +02:00
|
|
|
}
|
|
|
|
|
2021-11-03 14:26:26 +02:00
|
|
|
public get supportsLocks() {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2020-12-28 13:48:47 +02:00
|
|
|
public requestRepeatCount() {
|
|
|
|
return 3;
|
|
|
|
}
|
|
|
|
|
2023-06-30 10:11:26 +02:00
|
|
|
private metadataToStat_(md: any, path: string, isDeleted = false, rootPath: string) {
|
2023-12-31 18:06:02 +02:00
|
|
|
const output: any = {
|
2021-05-13 18:57:37 +02:00
|
|
|
path: rootPath ? path.substr(rootPath.length + 1) : path,
|
2020-12-28 13:48:47 +02:00
|
|
|
updated_time: md.updated_time,
|
2021-06-19 11:34:44 +02:00
|
|
|
jop_updated_time: md.jop_updated_time,
|
|
|
|
isDir: false,
|
2020-12-28 13:48:47 +02:00
|
|
|
isDeleted: isDeleted,
|
|
|
|
};
|
|
|
|
|
2023-12-31 18:06:02 +02:00
|
|
|
// Only add this object is it's also present in the raw data. This is
|
|
|
|
// because `getSupportsDeltaWithItems()` relies on it being present or
|
|
|
|
// not to decide if the sync target supports "delta with items".
|
|
|
|
if ('jopItem' in md) output.jopItem = md.jopItem;
|
|
|
|
|
2020-12-28 13:48:47 +02:00
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
private metadataToStats_(mds: any[], rootPath: string) {
|
2020-12-28 13:48:47 +02:00
|
|
|
const output = [];
|
|
|
|
for (let i = 0; i < mds.length; i++) {
|
2021-05-13 18:57:37 +02:00
|
|
|
output.push(this.metadataToStat_(mds[i], mds[i].name, false, rootPath));
|
2020-12-28 13:48:47 +02:00
|
|
|
}
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
2021-02-01 12:48:37 +02:00
|
|
|
// Transforms a path such as "Apps/Joplin/file.txt" to a complete a complete
|
2021-05-13 18:57:37 +02:00
|
|
|
// API URL path: "api/items/root:/Apps/Joplin/file.txt:"
|
2020-12-28 13:48:47 +02:00
|
|
|
private apiFilePath_(p: string) {
|
2021-05-13 18:57:37 +02:00
|
|
|
return `api/items/root:/${trimSlashes(p)}:`;
|
2020-12-28 13:48:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public async stat(path: string) {
|
|
|
|
try {
|
|
|
|
const response = await this.api().exec('GET', this.apiFilePath_(path));
|
2021-05-13 18:57:37 +02:00
|
|
|
return this.metadataToStat_(response, path, false, '');
|
2020-12-28 13:48:47 +02:00
|
|
|
} catch (error) {
|
|
|
|
if (error.code === 404) return null;
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public async delta(path: string, options: any) {
|
|
|
|
const context = options ? options.context : null;
|
|
|
|
let cursor = context ? context.cursor : null;
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
try {
|
|
|
|
const query = cursor ? { cursor } : {};
|
|
|
|
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
|
2021-05-13 18:57:37 +02:00
|
|
|
const stats = response.items
|
|
|
|
.filter((item: any) => {
|
2021-06-06 19:14:12 +02:00
|
|
|
// We don't need to know about lock changes, since this
|
|
|
|
// is handled by the LockHandler.
|
|
|
|
if (item.item_name.indexOf('locks/') === 0) return false;
|
|
|
|
|
|
|
|
// We don't need to sync what's in the temp folder
|
|
|
|
if (item.item_name.indexOf('temp/') === 0) return false;
|
|
|
|
|
|
|
|
// Although we sync the content of .resource, whether we
|
|
|
|
// fetch or upload data to it is driven by the
|
|
|
|
// associated resource item (.md) file. So at this point
|
|
|
|
// we don't want to automatically fetch from it.
|
|
|
|
if (item.item_name.indexOf('.resource/') === 0) return false;
|
|
|
|
return true;
|
2021-05-13 18:57:37 +02:00
|
|
|
})
|
|
|
|
.map((item: any) => {
|
|
|
|
return this.metadataToStat_(item, item.item_name, item.type === 3, '');
|
|
|
|
});
|
2020-12-28 13:48:47 +02:00
|
|
|
|
|
|
|
const output = {
|
|
|
|
items: stats,
|
|
|
|
hasMore: response.has_more,
|
|
|
|
context: { cursor: response.cursor },
|
|
|
|
};
|
|
|
|
|
|
|
|
return output;
|
|
|
|
} catch (error) {
|
|
|
|
// If there's an error related to an invalid cursor, clear the cursor and retry.
|
|
|
|
if (cursor && error.code === 'resyncRequired') {
|
|
|
|
cursor = null;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public async list(path: string, options: any = null) {
|
|
|
|
options = {
|
|
|
|
context: null,
|
|
|
|
...options,
|
|
|
|
};
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
let isUsingWildcard = false;
|
|
|
|
let searchPath = path;
|
|
|
|
if (searchPath) {
|
|
|
|
searchPath += '/*';
|
|
|
|
isUsingWildcard = true;
|
|
|
|
}
|
|
|
|
|
2020-12-28 13:48:47 +02:00
|
|
|
const query = options.context?.cursor ? { cursor: options.context.cursor } : null;
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
const results = await this.api().exec('GET', `${this.apiFilePath_(searchPath)}/children`, query);
|
2020-12-28 13:48:47 +02:00
|
|
|
|
|
|
|
const newContext: any = {};
|
|
|
|
if (results.cursor) newContext.cursor = results.cursor;
|
|
|
|
|
|
|
|
return {
|
2021-05-13 18:57:37 +02:00
|
|
|
items: this.metadataToStats_(results.items, isUsingWildcard ? path : ''),
|
2020-12-28 13:48:47 +02:00
|
|
|
hasMore: results.has_more,
|
|
|
|
context: newContext,
|
|
|
|
} as any;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async get(path: string, options: any) {
|
|
|
|
if (!options) options = {};
|
|
|
|
if (!options.responseFormat) options.responseFormat = 'text';
|
|
|
|
try {
|
|
|
|
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/content`, null, null, null, options);
|
|
|
|
return response;
|
|
|
|
} catch (error) {
|
|
|
|
if (error.code !== 404) throw error;
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
public async mkdir(_path: string) {
|
|
|
|
// This is a no-op because all items technically are at the root, but
|
|
|
|
// they can have names such as ".resources/xxxxxxxxxx'
|
2020-12-28 13:48:47 +02:00
|
|
|
}
|
|
|
|
|
2023-05-15 18:49:26 +02:00
|
|
|
private isRejectedBySyncTargetError(error: any) {
|
|
|
|
return error.code === 413 || error.code === 409 || error.httpCode === 413 || error.httpCode === 409;
|
|
|
|
}
|
|
|
|
|
2023-07-16 18:42:42 +02:00
|
|
|
private isReadyOnlyError(error: any) {
|
|
|
|
return error && error.code === 'isReadOnly';
|
|
|
|
}
|
|
|
|
|
2020-12-28 13:48:47 +02:00
|
|
|
public async put(path: string, content: any, options: any = null) {
|
2021-05-17 18:55:39 +02:00
|
|
|
try {
|
|
|
|
const output = await this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, options && options.shareId ? { share_id: options.shareId } : null, content, {
|
|
|
|
'Content-Type': 'application/octet-stream',
|
|
|
|
}, options);
|
|
|
|
return output;
|
|
|
|
} catch (error) {
|
2023-05-15 18:49:26 +02:00
|
|
|
if (this.isRejectedBySyncTargetError(error)) {
|
2021-05-17 18:55:39 +02:00
|
|
|
throw new JoplinError(error.message, 'rejectedByTarget');
|
|
|
|
}
|
2023-07-16 18:42:42 +02:00
|
|
|
|
|
|
|
if (this.isReadyOnlyError(error)) {
|
|
|
|
throw new JoplinError(error.message, 'isReadOnly');
|
|
|
|
}
|
|
|
|
|
2021-05-17 18:55:39 +02:00
|
|
|
throw error;
|
|
|
|
}
|
2020-12-28 13:48:47 +02:00
|
|
|
}
|
|
|
|
|
2021-06-18 18:17:25 +02:00
|
|
|
public async multiPut(items: MultiPutItem[], options: any = null) {
|
2023-05-15 18:49:26 +02:00
|
|
|
const output = await this.api().exec('PUT', 'api/batch_items', null, { items: items }, null, options);
|
|
|
|
|
|
|
|
for (const [, response] of Object.entries<any>(output.items)) {
|
|
|
|
if (response.error && this.isRejectedBySyncTargetError(response.error)) {
|
|
|
|
response.error.code = 'rejectedByTarget';
|
2023-07-16 18:42:42 +02:00
|
|
|
} else if (response.error && this.isReadyOnlyError(response.error)) {
|
|
|
|
response.error.code = 'isReadOnly';
|
2023-05-15 18:49:26 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return output;
|
2021-06-18 18:17:25 +02:00
|
|
|
}
|
|
|
|
|
2020-12-28 13:48:47 +02:00
|
|
|
public async delete(path: string) {
|
|
|
|
return this.api().exec('DELETE', this.apiFilePath_(path));
|
|
|
|
}
|
|
|
|
|
|
|
|
public format() {
|
|
|
|
throw new Error('Not supported');
|
|
|
|
}
|
|
|
|
|
2021-11-03 14:26:26 +02:00
|
|
|
// private lockClientTypeToId(clientType:AppType):number {
|
|
|
|
// if (clientType === AppType.Desktop) return 1;
|
|
|
|
// if (clientType === AppType.Mobile) return 2;
|
|
|
|
// if (clientType === AppType.Cli) return 3;
|
|
|
|
// throw new Error('Invalid client type: ' + clientType);
|
|
|
|
// }
|
|
|
|
|
|
|
|
// private lockTypeToId(lockType:LockType):number {
|
|
|
|
// if (lockType === LockType.None) return 0; // probably not possible?
|
|
|
|
// if (lockType === LockType.Sync) return 1;
|
|
|
|
// if (lockType === LockType.Exclusive) return 2;
|
|
|
|
// throw new Error('Invalid lock type: ' + lockType);
|
|
|
|
// }
|
|
|
|
|
|
|
|
// private lockClientIdTypeToType(clientType:number):AppType {
|
|
|
|
// if (clientType === 1) return AppType.Desktop;
|
|
|
|
// if (clientType === 2) return AppType.Mobile;
|
|
|
|
// if (clientType === 3) return AppType.Cli;
|
|
|
|
// throw new Error('Invalid client type: ' + clientType);
|
|
|
|
// }
|
|
|
|
|
|
|
|
// private lockIdToType(lockType:number):LockType {
|
|
|
|
// if (lockType === 0) return LockType.None; // probably not possible?
|
|
|
|
// if (lockType === 1) return LockType.Sync;
|
|
|
|
// if (lockType === 2) return LockType.Exclusive;
|
|
|
|
// throw new Error('Invalid lock type: ' + lockType);
|
|
|
|
// }
|
|
|
|
|
|
|
|
public async acquireLock(type: LockType, clientType: LockClientType, clientId: string): Promise<Lock> {
|
|
|
|
return this.api().exec('POST', 'api/locks', null, {
|
|
|
|
type,
|
|
|
|
clientType,
|
|
|
|
clientId: clientId,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public async releaseLock(type: LockType, clientType: LockClientType, clientId: string) {
|
|
|
|
await this.api().exec('DELETE', `api/locks/${type}_${clientType}_${clientId}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
public async listLocks() {
|
|
|
|
return this.api().exec('GET', 'api/locks');
|
|
|
|
}
|
|
|
|
|
2020-12-28 13:48:47 +02:00
|
|
|
public async clearRoot(path: string) {
|
2021-06-07 15:46:35 +02:00
|
|
|
const response = await this.list(path);
|
|
|
|
|
|
|
|
for (const item of response.items) {
|
|
|
|
await this.delete(item.path);
|
|
|
|
}
|
|
|
|
|
2021-11-03 14:26:26 +02:00
|
|
|
await this.api().exec('POST', 'api/debug', null, { action: 'clearKeyValues' });
|
|
|
|
|
2021-06-07 15:46:35 +02:00
|
|
|
if (response.has_more) throw new Error('has_more support not implemented');
|
2020-12-28 13:48:47 +02:00
|
|
|
}
|
|
|
|
}
|