2021-01-22 19:41:11 +02:00
import BaseModel from '../BaseModel' ;
import BaseItem from './BaseItem' ;
import ItemChange from './ItemChange' ;
import NoteResource from './NoteResource' ;
import Setting from './Setting' ;
import markdownUtils from '../markdownUtils' ;
import { _ } from '../locale' ;
import { ResourceEntity , ResourceLocalStateEntity } from '../services/database/types' ;
2021-01-23 17:51:19 +02:00
import ResourceLocalState from './ResourceLocalState' ;
2020-11-05 18:58:23 +02:00
const pathUtils = require ( '../path-utils' ) ;
const { mime } = require ( '../mime-utils.js' ) ;
const { filename , safeFilename } = require ( '../path-utils' ) ;
const { FsDriverDummy } = require ( '../fs-driver-dummy.js' ) ;
2021-05-13 18:57:37 +02:00
import JoplinError from '../JoplinError' ;
2021-06-15 18:17:12 +02:00
import itemCanBeEncrypted from './utils/itemCanBeEncrypted' ;
2021-08-12 17:54:10 +02:00
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils' ;
2017-06-24 20:51:43 +02:00
2021-01-22 19:41:11 +02:00
export default class Resource extends BaseItem {
public static IMAGE_MAX_DIMENSION = 1920 ;
public static FETCH_STATUS_IDLE = 0 ;
public static FETCH_STATUS_STARTED = 1 ;
public static FETCH_STATUS_DONE = 2 ;
public static FETCH_STATUS_ERROR = 3 ;
public static fsDriver_ : any ;
2017-06-24 20:51:43 +02:00
static tableName() {
2018-03-09 22:59:12 +02:00
return 'resources' ;
2017-06-24 20:51:43 +02:00
}
2017-07-03 21:50:45 +02:00
static modelType() {
return BaseModel . TYPE_RESOURCE ;
2017-06-24 20:51:43 +02:00
}
2017-12-19 21:01:29 +02:00
static encryptionService() {
2018-03-09 22:59:12 +02:00
if ( ! this . encryptionService_ ) throw new Error ( 'Resource.encryptionService_ is not set!!' ) ;
2017-12-19 21:01:29 +02:00
return this . encryptionService_ ;
}
2021-01-22 19:41:11 +02:00
static isSupportedImageMimeType ( type : string ) {
2020-08-18 22:52:00 +02:00
const imageMimeTypes = [ 'image/jpg' , 'image/jpeg' , 'image/png' , 'image/gif' , 'image/svg+xml' , 'image/webp' ] ;
return imageMimeTypes . indexOf ( type . toLowerCase ( ) ) >= 0 ;
2017-08-01 23:40:14 +02:00
}
2021-01-29 20:45:11 +02:00
static fetchStatuses ( resourceIds : string [ ] ) : Promise < any [ ] > {
if ( ! resourceIds . length ) return Promise . resolve ( [ ] ) ;
2019-09-19 23:51:18 +02:00
return this . db ( ) . selectAll ( ` SELECT resource_id, fetch_status FROM resource_local_states WHERE resource_id IN (" ${ resourceIds . join ( '","' ) } ") ` ) ;
2019-06-15 22:48:37 +02:00
}
2021-01-29 20:45:11 +02:00
public static sharedResourceIds ( ) : Promise < string [ ] > {
2021-05-13 18:57:37 +02:00
return this . db ( ) . selectAllFields ( 'SELECT id FROM resources WHERE is_shared = 1' , [ ] , 'id' ) ;
2021-01-29 20:45:11 +02:00
}
2019-12-28 21:23:38 +02:00
static errorFetchStatuses() {
return this . db ( ) . selectAll ( `
SELECT title AS resource_title , resource_id , fetch_error
FROM resource_local_states
LEFT JOIN resources ON resources . id = resource_local_states . resource_id
WHERE fetch_status = ?
` , [Resource.FETCH_STATUS_ERROR]);
}
2021-01-22 19:41:11 +02:00
static needToBeFetched ( resourceDownloadMode : string = null , limit : number = null ) {
2020-03-14 01:46:14 +02:00
const sql = [ 'SELECT * FROM resources WHERE encryption_applied = 0 AND id IN (SELECT resource_id FROM resource_local_states WHERE fetch_status = ?)' ] ;
2019-05-22 16:56:07 +02:00
if ( resourceDownloadMode !== 'always' ) {
sql . push ( 'AND resources.id IN (SELECT resource_id FROM resources_to_download)' ) ;
}
sql . push ( 'ORDER BY updated_time DESC' ) ;
2019-09-19 23:51:18 +02:00
if ( limit !== null ) sql . push ( ` LIMIT ${ limit } ` ) ;
2019-05-22 16:56:07 +02:00
return this . modelSelectAll ( sql . join ( ' ' ) , [ Resource . FETCH_STATUS_IDLE ] ) ;
2018-11-14 00:25:23 +02:00
}
2019-03-08 19:14:17 +02:00
static async resetStartedFetchStatus() {
return await this . db ( ) . exec ( 'UPDATE resource_local_states SET fetch_status = ? WHERE fetch_status = ?' , [ Resource . FETCH_STATUS_IDLE , Resource . FETCH_STATUS_STARTED ] ) ;
}
2021-01-22 19:41:11 +02:00
static resetErrorStatus ( resourceId : string ) {
2019-12-28 21:23:38 +02:00
return this . db ( ) . exec ( 'UPDATE resource_local_states SET fetch_status = ?, fetch_error = "" WHERE resource_id = ?' , [ Resource . FETCH_STATUS_IDLE , resourceId ] ) ;
}
2017-07-05 23:52:31 +02:00
static fsDriver() {
if ( ! Resource . fsDriver_ ) Resource . fsDriver_ = new FsDriverDummy ( ) ;
return Resource . fsDriver_ ;
}
2020-05-30 14:25:05 +02:00
// DEPRECATED IN FAVOUR OF friendlySafeFilename()
2021-01-22 19:41:11 +02:00
static friendlyFilename ( resource : ResourceEntity ) {
2018-09-30 11:15:46 +02:00
let output = safeFilename ( resource . title ) ; // Make sure not to allow spaces or any special characters as it's not supported in HTTP headers
if ( ! output ) output = resource . id ;
let extension = resource . file_extension ;
if ( ! extension ) extension = resource . mime ? mime . toFileExtension ( resource . mime ) : '' ;
2019-09-19 23:51:18 +02:00
extension = extension ? ` . ${ extension } ` : '' ;
2018-09-30 11:15:46 +02:00
return output + extension ;
}
2019-01-20 18:27:33 +02:00
static baseDirectoryPath() {
return Setting . value ( 'resourceDir' ) ;
}
2019-05-11 12:46:13 +02:00
static baseRelativeDirectoryPath() {
return Setting . value ( 'resourceDirName' ) ;
}
2021-01-22 19:41:11 +02:00
static filename ( resource : ResourceEntity , encryptedBlob = false ) {
2019-05-22 16:56:07 +02:00
let extension = encryptedBlob ? 'crypted' : resource . file_extension ;
if ( ! extension ) extension = resource . mime ? mime . toFileExtension ( resource . mime ) : '' ;
2019-09-19 23:51:18 +02:00
extension = extension ? ` . ${ extension } ` : '' ;
2019-05-22 16:56:07 +02:00
return resource . id + extension ;
}
2021-01-22 19:41:11 +02:00
static friendlySafeFilename ( resource : ResourceEntity ) {
2020-06-09 21:15:43 +02:00
let ext = resource . file_extension ;
2020-05-30 14:25:05 +02:00
if ( ! ext ) ext = resource . mime ? mime . toFileExtension ( resource . mime ) : '' ;
const safeExt = ext ? pathUtils . safeFileExtension ( ext ) . toLowerCase ( ) : '' ;
let title = resource . title ? resource.title : resource.id ;
if ( safeExt && pathUtils . fileExtension ( title ) . toLowerCase ( ) === safeExt ) title = pathUtils . filename ( title ) ;
return pathUtils . friendlySafeFilename ( title ) + ( safeExt ? ` . ${ safeExt } ` : '' ) ;
}
2021-01-22 19:41:11 +02:00
static relativePath ( resource : ResourceEntity , encryptedBlob = false ) {
2019-09-19 23:51:18 +02:00
return ` ${ Setting . value ( 'resourceDirName' ) } / ${ this . filename ( resource , encryptedBlob ) } ` ;
2019-05-11 12:46:13 +02:00
}
2021-01-22 19:41:11 +02:00
static fullPath ( resource : ResourceEntity , encryptedBlob = false ) {
2019-09-19 23:51:18 +02:00
return ` ${ Setting . value ( 'resourceDir' ) } / ${ this . filename ( resource , encryptedBlob ) } ` ;
2017-12-19 21:01:29 +02:00
}
2021-01-22 19:41:11 +02:00
static async isReady ( resource : ResourceEntity ) {
2020-05-31 01:31:29 +02:00
const r = await this . readyStatus ( resource ) ;
return r === 'ok' ;
}
2021-01-22 19:41:11 +02:00
static async readyStatus ( resource : ResourceEntity ) {
2018-11-13 02:45:08 +02:00
const ls = await this . localState ( resource ) ;
2020-05-31 01:31:29 +02:00
if ( ! resource ) return 'notFound' ;
if ( ls . fetch_status !== Resource . FETCH_STATUS_DONE ) return 'notDownloaded' ;
if ( resource . encryption_blob_encrypted ) return 'encrypted' ;
return 'ok' ;
2018-10-08 20:11:53 +02:00
}
2021-01-22 19:41:11 +02:00
static async requireIsReady ( resource : ResourceEntity ) {
2020-05-31 17:57:16 +02:00
const readyStatus = await Resource . readyStatus ( resource ) ;
if ( readyStatus !== 'ok' ) throw new Error ( ` Resource is not ready. Status: ${ readyStatus } ` ) ;
}
2017-12-19 21:01:29 +02:00
// For resources, we need to decrypt the item (metadata) and the resource binary blob.
2021-01-22 19:41:11 +02:00
static async decrypt ( item : ResourceEntity ) {
2017-12-21 21:06:08 +02:00
// The item might already be decrypted but not the blob (for instance if it crashes while
// decrypting the blob or was otherwise interrupted).
const decryptedItem = item . encryption_cipher_text ? await super . decrypt ( item ) : Object . assign ( { } , item ) ;
2017-12-19 21:01:29 +02:00
if ( ! decryptedItem . encryption_blob_encrypted ) return decryptedItem ;
2019-05-22 16:56:07 +02:00
const localState = await this . localState ( item ) ;
if ( localState . fetch_status !== Resource . FETCH_STATUS_DONE ) {
// Not an error - it means the blob has not been downloaded yet.
// It will be decrypted later on, once downloaded.
return decryptedItem ;
}
2017-12-19 21:01:29 +02:00
const plainTextPath = this . fullPath ( decryptedItem ) ;
const encryptedPath = this . fullPath ( decryptedItem , true ) ;
2019-09-19 23:51:18 +02:00
const noExtPath = ` ${ pathUtils . dirname ( encryptedPath ) } / ${ pathUtils . filename ( encryptedPath ) } ` ;
2018-10-11 18:18:33 +02:00
2017-12-19 21:01:29 +02:00
// When the resource blob is downloaded by the synchroniser, it's initially a file with no
// extension (since it's encrypted, so we don't know its extension). So here rename it
// to a file with a ".crypted" extension so that it's better identified, and then decrypt it.
// Potentially plainTextPath is also a path with no extension if it's an unknown mime type.
if ( await this . fsDriver ( ) . exists ( noExtPath ) ) {
await this . fsDriver ( ) . move ( noExtPath , encryptedPath ) ;
}
2018-04-22 14:33:12 +02:00
try {
2019-05-22 16:56:07 +02:00
await this . encryptionService ( ) . decryptFile ( encryptedPath , plainTextPath ) ;
2018-04-22 14:33:12 +02:00
} catch ( error ) {
if ( error . code === 'invalidIdentifier' ) {
// As the identifier is invalid it most likely means that this is not encrypted data
// at all. It can happen for example when there's a crash between the moment the data
// is decrypted and the resource item is updated.
2019-09-19 23:51:18 +02:00
this . logger ( ) . warn ( ` Found a resource that was most likely already decrypted but was marked as encrypted. Marked it as decrypted: ${ item . id } ` ) ;
2018-04-22 14:33:12 +02:00
this . fsDriver ( ) . move ( encryptedPath , plainTextPath ) ;
} else {
throw error ;
}
}
2018-01-02 21:17:14 +02:00
2017-12-21 21:06:08 +02:00
decryptedItem . encryption_blob_encrypted = 0 ;
2018-01-02 21:17:14 +02:00
return super . save ( decryptedItem , { autoTimestamp : false } ) ;
2017-12-19 21:01:29 +02:00
}
// Prepare the resource by encrypting it if needed.
2017-12-04 21:01:56 +02:00
// The call returns the path to the physical file AND a representation of the resource object
// as it should be uploaded to the sync target. Note that this may be different from what is stored
// in the database. In particular, the flag encryption_blob_encrypted might be 1 on the sync target
// if the resource is encrypted, but will be 0 locally because the device has the decrypted resource.
2021-06-15 18:17:12 +02:00
public static async fullPathForSyncUpload ( resource : ResourceEntity ) {
2017-12-19 21:01:29 +02:00
const plainTextPath = this . fullPath ( resource ) ;
2021-08-12 17:54:10 +02:00
if ( ! getEncryptionEnabled ( ) || ! itemCanBeEncrypted ( resource as any ) ) {
2018-01-02 21:17:14 +02:00
// Normally not possible since itemsThatNeedSync should only return decrypted items
2019-07-29 15:43:53 +02:00
if ( resource . encryption_blob_encrypted ) throw new Error ( 'Trying to access encrypted resource but encryption is currently disabled' ) ;
2017-12-19 21:01:29 +02:00
return { path : plainTextPath , resource : resource } ;
}
const encryptedPath = this . fullPath ( resource , true ) ;
if ( resource . encryption_blob_encrypted ) return { path : encryptedPath , resource : resource } ;
2018-01-28 19:37:03 +02:00
try {
2019-05-22 16:56:07 +02:00
await this . encryptionService ( ) . encryptFile ( plainTextPath , encryptedPath ) ;
2018-01-28 19:37:03 +02:00
} catch ( error ) {
2019-09-19 23:51:18 +02:00
if ( error . code === 'ENOENT' ) throw new JoplinError ( ` File not found: ${ error . toString ( ) } ` , 'fileNotFound' ) ;
2018-01-28 19:37:03 +02:00
throw error ;
}
2017-12-19 21:01:29 +02:00
2017-12-04 21:01:56 +02:00
const resourceCopy = Object . assign ( { } , resource ) ;
resourceCopy . encryption_blob_encrypted = 1 ;
return { path : encryptedPath , resource : resourceCopy } ;
2017-06-24 20:51:43 +02:00
}
2021-01-22 19:41:11 +02:00
static markdownTag ( resource : any ) {
2017-08-01 23:40:14 +02:00
let tagAlt = resource . alt ? resource.alt : resource.title ;
2018-03-09 22:59:12 +02:00
if ( ! tagAlt ) tagAlt = '' ;
2020-03-14 01:46:14 +02:00
const lines = [ ] ;
2020-08-18 22:52:00 +02:00
if ( Resource . isSupportedImageMimeType ( resource . mime ) ) {
2019-07-29 15:43:53 +02:00
lines . push ( '![' ) ;
2020-06-07 13:55:40 +02:00
lines . push ( markdownUtils . escapeTitleText ( tagAlt ) ) ;
2019-09-19 23:51:18 +02:00
lines . push ( ` ](:/ ${ resource . id } ) ` ) ;
2017-08-01 23:40:14 +02:00
} else {
2019-07-29 15:43:53 +02:00
lines . push ( '[' ) ;
2020-06-07 13:55:40 +02:00
lines . push ( markdownUtils . escapeTitleText ( tagAlt ) ) ;
2019-09-19 23:51:18 +02:00
lines . push ( ` ](:/ ${ resource . id } ) ` ) ;
2017-08-01 23:40:14 +02:00
}
2018-03-09 22:59:12 +02:00
return lines . join ( '' ) ;
2017-08-01 23:40:14 +02:00
}
2021-01-22 19:41:11 +02:00
static internalUrl ( resource : ResourceEntity ) {
2019-09-19 23:51:18 +02:00
return ` :/ ${ resource . id } ` ;
2018-05-23 13:14:38 +02:00
}
2021-01-22 19:41:11 +02:00
static pathToId ( path : string ) {
2017-06-25 01:19:11 +02:00
return filename ( path ) ;
}
2021-01-22 19:41:11 +02:00
static async content ( resource : ResourceEntity ) {
2018-03-09 22:59:12 +02:00
return this . fsDriver ( ) . readFile ( this . fullPath ( resource ) , 'Buffer' ) ;
2017-07-02 14:02:07 +02:00
}
2021-08-11 12:24:01 +02:00
static setContent ( resource : ResourceEntity , content : any ) {
return this . fsDriver ( ) . writeBinaryFile ( this . fullPath ( resource ) , content ) ;
}
2021-01-22 19:41:11 +02:00
static isResourceUrl ( url : string ) {
2018-03-09 22:59:12 +02:00
return url && url . length === 34 && url [ 0 ] === ':' && url [ 1 ] === '/' ;
2017-07-26 20:36:16 +02:00
}
2021-01-22 19:41:11 +02:00
static urlToId ( url : string ) {
2019-09-19 23:51:18 +02:00
if ( ! this . isResourceUrl ( url ) ) throw new Error ( ` Not a valid resource URL: ${ url } ` ) ;
2017-07-26 20:36:16 +02:00
return url . substr ( 2 ) ;
}
2018-03-09 22:59:12 +02:00
2021-01-22 19:41:11 +02:00
static async localState ( resourceOrId : any ) {
2018-11-13 02:45:08 +02:00
return ResourceLocalState . byResourceId ( typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId ) ;
}
2021-01-22 19:41:11 +02:00
static async setLocalState ( resourceOrId : any , state : ResourceLocalStateEntity ) {
2018-11-13 02:45:08 +02:00
const id = typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId ;
await ResourceLocalState . save ( Object . assign ( { } , state , { resource_id : id } ) ) ;
}
2019-05-12 16:53:42 +02:00
static async needFileSizeSet() {
return this . modelSelectAll ( 'SELECT * FROM resources WHERE `size` < 0 AND encryption_blob_encrypted = 0' ) ;
}
2019-05-12 12:41:07 +02:00
// Only set the `size` field and nothing else, not even the update_time
// This is because it's only necessary to do it once after migration 20
// and each client does it so there's no need to sync the resource.
2021-01-22 19:41:11 +02:00
static async setFileSizeOnly ( resourceId : string , fileSize : number ) {
2019-05-12 12:41:07 +02:00
return this . db ( ) . exec ( 'UPDATE resources set `size` = ? WHERE id = ?' , [ fileSize , resourceId ] ) ;
}
2021-01-22 19:41:11 +02:00
static async batchDelete ( ids : string [ ] , options : any = null ) {
2018-03-15 19:46:54 +02:00
// For resources, there's not really batch deleting since there's the file data to delete
// too, so each is processed one by one with the item being deleted last (since the db
// call is the less likely to fail).
for ( let i = 0 ; i < ids . length ; i ++ ) {
const id = ids [ i ] ;
const resource = await Resource . load ( id ) ;
2018-04-22 14:33:12 +02:00
if ( ! resource ) continue ;
2018-03-15 19:46:54 +02:00
const path = Resource . fullPath ( resource ) ;
await this . fsDriver ( ) . remove ( path ) ;
2018-03-16 19:39:44 +02:00
await super . batchDelete ( [ id ] , options ) ;
await NoteResource . deleteByResource ( id ) ; // Clean up note/resource relationships
2018-03-15 19:46:54 +02:00
}
2018-11-13 02:45:08 +02:00
await ResourceLocalState . batchDelete ( ids ) ;
2018-03-15 19:46:54 +02:00
}
2021-01-22 19:41:11 +02:00
static async markForDownload ( resourceId : string ) {
2019-05-22 16:56:07 +02:00
// Insert the row only if it's not already there
const t = Date . now ( ) ;
await this . db ( ) . exec ( 'INSERT INTO resources_to_download (resource_id, updated_time, created_time) SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM resources_to_download WHERE resource_id = ?)' , [ resourceId , t , t , resourceId ] ) ;
}
2021-01-22 19:41:11 +02:00
static async downloadedButEncryptedBlobCount ( excludedIds : string [ ] = null ) {
2020-04-08 02:00:01 +02:00
let excludedSql = '' ;
if ( excludedIds && excludedIds . length ) {
excludedSql = ` AND resource_id NOT IN (" ${ excludedIds . join ( '","' ) } ") ` ;
}
2019-12-28 21:23:38 +02:00
const r = await this . db ( ) . selectOne ( `
SELECT count ( * ) as total
FROM resource_local_states
2020-04-08 02:00:01 +02:00
WHERE fetch_status = ?
AND resource_id IN ( SELECT id FROM resources WHERE encryption_blob_encrypted = 1 )
$ { excludedSql }
2019-12-28 21:23:38 +02:00
` , [Resource.FETCH_STATUS_DONE]);
2019-05-22 16:56:07 +02:00
return r ? r.total : 0 ;
}
2019-12-28 21:23:38 +02:00
2021-01-22 19:41:11 +02:00
static async downloadStatusCounts ( status : number ) {
2019-12-28 21:23:38 +02:00
const r = await this . db ( ) . selectOne ( `
SELECT count ( * ) as total
FROM resource_local_states
WHERE fetch_status = ?
` , [status]);
return r ? r.total : 0 ;
}
2020-12-30 20:35:18 +02:00
static async createdLocallyCount() {
const r = await this . db ( ) . selectOne ( `
SELECT count ( * ) as total
FROM resources
WHERE id NOT IN
( SELECT resource_id FROM resource_local_states )
` );
return r ? r.total : 0 ;
}
2021-01-22 19:41:11 +02:00
static fetchStatusToLabel ( status : number ) {
2019-12-28 21:23:38 +02:00
if ( status === Resource . FETCH_STATUS_IDLE ) return _ ( 'Not downloaded' ) ;
if ( status === Resource . FETCH_STATUS_STARTED ) return _ ( 'Downloading' ) ;
if ( status === Resource . FETCH_STATUS_DONE ) return _ ( 'Downloaded' ) ;
if ( status === Resource . FETCH_STATUS_ERROR ) return _ ( 'Error' ) ;
throw new Error ( ` Invalid status: ${ status } ` ) ;
}
2021-01-22 19:41:11 +02:00
static async updateResourceBlobContent ( resourceId : string , newBlobFilePath : string ) {
2020-05-31 17:57:16 +02:00
const resource = await Resource . load ( resourceId ) ;
await this . requireIsReady ( resource ) ;
const fileStat = await this . fsDriver ( ) . stat ( newBlobFilePath ) ;
await this . fsDriver ( ) . copy ( newBlobFilePath , Resource . fullPath ( resource ) ) ;
2020-05-31 18:43:51 +02:00
return await Resource . save ( {
2020-05-31 17:57:16 +02:00
id : resource.id ,
size : fileStat.size ,
} ) ;
}
2021-01-22 19:41:11 +02:00
static async resourceBlobContent ( resourceId : string , encoding = 'Buffer' ) {
2020-05-31 17:57:16 +02:00
const resource = await Resource . load ( resourceId ) ;
await this . requireIsReady ( resource ) ;
return await this . fsDriver ( ) . readFile ( Resource . fullPath ( resource ) , encoding ) ;
}
2021-01-22 19:41:11 +02:00
static async duplicateResource ( resourceId : string ) {
2020-05-31 18:43:51 +02:00
const resource = await Resource . load ( resourceId ) ;
const localState = await Resource . localState ( resource ) ;
let newResource = { . . . resource } ;
delete newResource . id ;
newResource = await Resource . save ( newResource ) ;
const newLocalState = { . . . localState } ;
newLocalState . resource_id = newResource . id ;
delete newLocalState . id ;
await Resource . setLocalState ( newResource , newLocalState ) ;
const sourcePath = Resource . fullPath ( resource ) ;
if ( await this . fsDriver ( ) . exists ( sourcePath ) ) {
await this . fsDriver ( ) . copy ( sourcePath , Resource . fullPath ( newResource ) ) ;
}
return newResource ;
}
2021-08-05 15:25:25 +02:00
public static async resourceConflictFolderId ( ) : Promise < string > {
const folder = await this . resourceConflictFolder ( ) ;
return folder . id ;
}
private static async resourceConflictFolder ( ) : Promise < any > {
const conflictFolderTitle = _ ( 'Conflicts (attachments)' ) ;
const Folder = this . getClass ( 'Folder' ) ;
const folder = await Folder . loadByTitle ( conflictFolderTitle ) ;
if ( ! folder || folder . parent_id ) {
return Folder . save ( { title : conflictFolderTitle } ) ;
}
return folder ;
}
2020-05-31 18:43:51 +02:00
2021-08-05 15:25:25 +02:00
public static async createConflictResourceNote ( resource : ResourceEntity ) {
const Note = this . getClass ( 'Note' ) ;
2020-05-31 18:43:51 +02:00
const conflictResource = await Resource . duplicateResource ( resource . id ) ;
await Note . save ( {
title : _ ( 'Attachment conflict: "%s"' , resource . title ) ,
2020-06-07 14:02:58 +02:00
body : _ ( 'There was a [conflict](%s) on the attachment below.\n\n%s' , 'https://joplinapp.org/conflict/' , Resource . markdownTag ( conflictResource ) ) ,
2021-08-05 15:25:25 +02:00
parent_id : await this . resourceConflictFolderId ( ) ,
2020-05-31 18:43:51 +02:00
} , { changeSource : ItemChange.SOURCE_SYNC } ) ;
}
2017-06-24 20:51:43 +02:00
}