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' ;
2023-12-13 21:24:58 +02:00
import { ResourceEntity , ResourceLocalStateEntity , ResourceOcrStatus , SqlQuery } 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' ;
2022-02-09 20:04:27 +02:00
import ShareService from '../services/share/ShareService' ;
2023-12-13 21:24:58 +02:00
import { LoadOptions } from './utils/types' ;
2023-10-24 11:46:33 +02:00
import { SaveOptions } from './utils/types' ;
2023-10-31 18:53:47 +02:00
import { MarkupLanguage } from '@joplin/renderer' ;
import { htmlentities } from '@joplin/utils/html' ;
2023-12-13 21:24:58 +02:00
import { RecognizeResultLine } from '../services/ocr/utils/types' ;
import eventManager , { EventName } from '../eventManager' ;
import { unique } from '../array' ;
2023-12-24 00:31:21 +02:00
import isSqliteSyntaxError from '../services/database/isSqliteSyntaxError' ;
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 ;
2022-02-09 20:04:27 +02:00
public static shareService_ : ShareService = null ;
2021-01-22 19:41:11 +02:00
public static fsDriver_ : any ;
2023-03-06 16:22:01 +02:00
public static tableName() {
2018-03-09 22:59:12 +02:00
return 'resources' ;
2017-06-24 20:51:43 +02:00
}
2023-03-06 16:22:01 +02:00
public static modelType() {
2017-07-03 21:50:45 +02:00
return BaseModel . TYPE_RESOURCE ;
2017-06-24 20:51:43 +02:00
}
2023-03-06 16:22:01 +02:00
public 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_ ;
}
2022-02-09 20:04:27 +02:00
protected static shareService() {
if ( ! this . shareService_ ) throw new Error ( 'Resource.shareService_ is not set!!' ) ;
return this . shareService_ ;
}
2023-03-06 16:22:01 +02:00
public static isSupportedImageMimeType ( type : string ) {
2023-06-05 19:20:15 +02:00
const imageMimeTypes = [ 'image/jpg' , 'image/jpeg' , 'image/png' , 'image/gif' , 'image/svg+xml' , 'image/webp' , 'image/avif' ] ;
2020-08-18 22:52:00 +02:00
return imageMimeTypes . indexOf ( type . toLowerCase ( ) ) >= 0 ;
2017-08-01 23:40:14 +02:00
}
2023-03-06 16:22:01 +02:00
public static fetchStatuses ( resourceIds : string [ ] ) : Promise < any [ ] > {
2021-01-29 20:45:11 +02:00
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
}
2023-03-06 16:22:01 +02:00
public static errorFetchStatuses() {
2019-12-28 21:23:38 +02:00
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]);
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
public static async resetStartedFetchStatus() {
2019-03-08 19:14:17 +02:00
return await this . db ( ) . exec ( 'UPDATE resource_local_states SET fetch_status = ? WHERE fetch_status = ?' , [ Resource . FETCH_STATUS_IDLE , Resource . FETCH_STATUS_STARTED ] ) ;
}
2023-12-13 21:24:58 +02:00
public static async resetFetchErrorStatus ( resourceId : string ) {
await this . db ( ) . exec ( 'UPDATE resource_local_states SET fetch_status = ?, fetch_error = "" WHERE resource_id = ?' , [ Resource . FETCH_STATUS_IDLE , resourceId ] ) ;
await this . resetOcrStatus ( resourceId ) ;
2019-12-28 21:23:38 +02:00
}
2023-03-06 16:22:01 +02:00
public static fsDriver() {
2017-07-05 23:52:31 +02:00
if ( ! Resource . fsDriver_ ) Resource . fsDriver_ = new FsDriverDummy ( ) ;
return Resource . fsDriver_ ;
}
2020-05-30 14:25:05 +02:00
// DEPRECATED IN FAVOUR OF friendlySafeFilename()
2023-03-06 16:22:01 +02:00
public 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 ;
}
2023-03-06 16:22:01 +02:00
public static baseDirectoryPath() {
2019-01-20 18:27:33 +02:00
return Setting . value ( 'resourceDir' ) ;
}
2023-03-06 16:22:01 +02:00
public static baseRelativeDirectoryPath() {
2019-05-11 12:46:13 +02:00
return Setting . value ( 'resourceDirName' ) ;
}
2023-03-06 16:22:01 +02:00
public 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 ;
}
2023-03-06 16:22:01 +02:00
public 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 } ` : '' ) ;
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
public static async isReady ( resource : ResourceEntity ) {
2020-05-31 01:31:29 +02:00
const r = await this . readyStatus ( resource ) ;
return r === 'ok' ;
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
public 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.
2023-03-06 16:22:01 +02:00
public 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).
2023-06-01 13:02:36 +02:00
const decryptedItem = item . encryption_cipher_text ? await super . decrypt ( item ) : { . . . 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 ) ;
2022-02-09 20:04:27 +02:00
const share = resource . share_id ? await this . shareService ( ) . shareById ( resource . share_id ) : null ;
2022-07-12 12:28:48 +02:00
if ( ! getEncryptionEnabled ( ) || ! itemCanBeEncrypted ( resource as any , share ) ) {
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 {
2022-02-09 20:04:27 +02:00
await this . encryptionService ( ) . encryptFile ( plainTextPath , encryptedPath , {
masterKeyId : share && share . master_key_id ? share . master_key_id : '' ,
} ) ;
2018-01-28 19:37:03 +02:00
} catch ( error ) {
2023-11-10 16:22:26 +02:00
if ( error . code === 'ENOENT' ) {
throw new JoplinError (
` Trying to encrypt resource but only metadata is present: ${ error . toString ( ) } ` , 'fileNotFound' ,
) ;
}
2018-01-28 19:37:03 +02:00
throw error ;
}
2017-12-19 21:01:29 +02:00
2023-06-01 13:02:36 +02:00
const resourceCopy = { . . . resource } ;
2017-12-04 21:01:56 +02:00
resourceCopy . encryption_blob_encrypted = 1 ;
return { path : encryptedPath , resource : resourceCopy } ;
2017-06-24 20:51:43 +02:00
}
2023-10-31 18:53:47 +02:00
public static markupTag ( resource : any , markupLanguage : MarkupLanguage = MarkupLanguage . Markdown ) {
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 ) ) {
2023-10-31 18:53:47 +02:00
if ( markupLanguage === MarkupLanguage . Markdown ) {
lines . push ( '![' ) ;
lines . push ( markdownUtils . escapeTitleText ( tagAlt ) ) ;
lines . push ( ` ](:/ ${ resource . id } ) ` ) ;
} else {
const altHtml = tagAlt ? ` alt=" ${ htmlentities ( tagAlt ) } " ` : '' ;
lines . push ( ` <img src=":/ ${ resource . id } " ${ altHtml } /> ` ) ;
}
2017-08-01 23:40:14 +02:00
} else {
2023-10-31 18:53:47 +02:00
if ( markupLanguage === MarkupLanguage . Markdown ) {
lines . push ( '[' ) ;
lines . push ( markdownUtils . escapeTitleText ( tagAlt ) ) ;
lines . push ( ` ](:/ ${ resource . id } ) ` ) ;
} else {
const altHtml = tagAlt ? ` alt=" ${ htmlentities ( tagAlt ) } " ` : '' ;
lines . push ( ` <a href=":/ ${ resource . id } " ${ altHtml } > ${ htmlentities ( tagAlt ? tagAlt : resource.id ) } </a> ` ) ;
}
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
}
2023-03-06 16:22:01 +02:00
public static internalUrl ( resource : ResourceEntity ) {
2019-09-19 23:51:18 +02:00
return ` :/ ${ resource . id } ` ;
2018-05-23 13:14:38 +02:00
}
2023-03-06 16:22:01 +02:00
public static pathToId ( path : string ) {
2017-06-25 01:19:11 +02:00
return filename ( path ) ;
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
public 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
2023-12-13 21:24:58 +02:00
public static async localState ( resourceOrId : any ) : Promise < ResourceLocalStateEntity > {
2018-11-13 02:45:08 +02:00
return ResourceLocalState . byResourceId ( typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId ) ;
}
2023-07-16 18:42:42 +02:00
public static setLocalStateQueries ( resourceOrId : any , state : ResourceLocalStateEntity ) {
const id = typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId ;
return ResourceLocalState . saveQueries ( { . . . state , resource_id : id } ) ;
}
2023-03-06 16:22:01 +02:00
public static async setLocalState ( resourceOrId : any , state : ResourceLocalStateEntity ) {
2018-11-13 02:45:08 +02:00
const id = typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId ;
2023-06-01 13:02:36 +02:00
await ResourceLocalState . save ( { . . . state , resource_id : id } ) ;
2018-11-13 02:45:08 +02:00
}
2023-03-06 16:22:01 +02:00
public static async needFileSizeSet() {
2019-05-12 16:53:42 +02:00
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.
2023-03-06 16:22:01 +02:00
public 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 ] ) ;
}
2023-03-06 16:22:01 +02:00
public static async batchDelete ( ids : string [ ] , options : any = null ) {
2023-07-16 18:42:42 +02:00
// For resources, there's not really batch deletion since there's the
// file data to delete too, so each is processed one by one with the
// file data being deleted last since the metadata deletion call may
// throw (for example if trying to delete a read-only item).
2018-03-15 19:46:54 +02:00
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 ) ;
2018-03-16 19:39:44 +02:00
await super . batchDelete ( [ id ] , options ) ;
2023-07-16 18:42:42 +02:00
await this . fsDriver ( ) . remove ( path ) ;
2018-03-16 19:39:44 +02:00
await NoteResource . deleteByResource ( id ) ; // Clean up note/resource relationships
2023-12-13 21:24:58 +02:00
await this . db ( ) . exec ( 'DELETE FROM items_normalized WHERE item_id = ?' , [ id ] ) ;
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
}
2023-03-06 16:22:01 +02:00
public 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 ] ) ;
}
2023-03-06 16:22:01 +02:00
public 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
2023-03-06 16:22:01 +02:00
public 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 ;
}
2023-03-06 16:22:01 +02:00
public static async createdLocallyCount() {
2020-12-30 20:35:18 +02:00
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 ;
}
2023-03-06 16:22:01 +02:00
public 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 } ` ) ;
}
2023-03-06 16:22:01 +02:00
public 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 ) ;
2023-07-16 18:42:42 +02:00
// We first save the resource metadata because this can throw, for
// example if modifying a resource that is read-only
2023-10-24 11:46:33 +02:00
const now = Date . now ( ) ;
2023-07-16 18:42:42 +02:00
const result = await Resource . save ( {
2020-05-31 17:57:16 +02:00
id : resource.id ,
size : fileStat.size ,
2023-10-24 11:46:33 +02:00
updated_time : now ,
blob_updated_time : now ,
} , {
autoTimestamp : false ,
2020-05-31 17:57:16 +02:00
} ) ;
2023-07-16 18:42:42 +02:00
// If the above call has succeeded, we save the data blob
await this . fsDriver ( ) . copy ( newBlobFilePath , Resource . fullPath ( resource ) ) ;
return result ;
2020-05-31 17:57:16 +02:00
}
2023-03-06 16:22:01 +02:00
public 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-11-27 18:05:28 +02:00
public static async duplicateResource ( resourceId : string ) : Promise < ResourceEntity > {
2020-05-31 18:43:51 +02:00
const resource = await Resource . load ( resourceId ) ;
const localState = await Resource . localState ( resource ) ;
2023-06-08 16:09:10 +02:00
let newResource : ResourceEntity = { . . . resource } ;
2020-05-31 18:43:51 +02:00
delete newResource . id ;
2023-06-08 16:09:10 +02:00
delete newResource . is_shared ;
2023-07-16 18:42:42 +02:00
delete newResource . share_id ;
2020-05-31 18:43:51 +02:00
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
2023-12-13 21:24:58 +02:00
public static mustHandleConflict ( local : ResourceEntity , remote : ResourceEntity ) {
// That shouldn't happen so throw an exception
if ( local . id !== remote . id ) throw new Error ( 'Cannot handle conflict for two different resources' ) ;
// If the content has changed, we need to handle the conflict
if ( local . blob_updated_time !== remote . blob_updated_time ) return true ;
// If nothing has been changed, or if only the metadata has been
// changed, we just keep the remote version. Most of the resource
// metadata is not user-editable so there won't be any data loss. Such a
// conflict might happen for example if a resource is OCRed by two
// different clients.
return false ;
}
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 ) ,
2023-10-31 18:53:47 +02:00
body : _ ( 'There was a [conflict](%s) on the attachment below.\n\n%s' , 'https://joplinapp.org/help/apps/conflict' , Resource . markupTag ( 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 } ) ;
}
2023-12-13 21:24:58 +02:00
private static baseNeedOcrQuery ( selectSql : string , supportedMimeTypes : string [ ] ) : SqlQuery {
return {
sql : `
SELECT $ { selectSql }
FROM resources
WHERE
ocr_status = ? AND
encryption_applied = 0 AND
mime IN ( "${supportedMimeTypes.join('" , "')}" )
` ,
params : [
ResourceOcrStatus . Todo ,
] ,
} ;
}
public static async needOcrCount ( supportedMimeTypes : string [ ] ) : Promise < number > {
const query = this . baseNeedOcrQuery ( 'count(*) as total' , supportedMimeTypes ) ;
const r = await this . db ( ) . selectOne ( query . sql , query . params ) ;
return r ? r [ 'total' ] : 0 ;
}
public static async needOcr ( supportedMimeTypes : string [ ] , skippedResourceIds : string [ ] , limit : number , options : LoadOptions ) : Promise < ResourceEntity [ ] > {
const query = this . baseNeedOcrQuery ( this . selectFields ( options ) , supportedMimeTypes ) ;
const skippedResourcesSql = skippedResourceIds . length ? ` AND resources.id NOT IN (" ${ skippedResourceIds . join ( '","' ) } ") ` : '' ;
return await this . db ( ) . selectAll ( `
$ { query . sql }
$ { skippedResourcesSql }
ORDER BY updated_time DESC
LIMIT $ { limit }
` , query.params);
}
private static async resetOcrStatus ( resourceId : string ) {
await Resource . save ( {
id : resourceId ,
ocr_error : '' ,
ocr_text : '' ,
ocr_status : ResourceOcrStatus.Todo ,
} ) ;
}
public static serializeOcrDetails ( details : RecognizeResultLine [ ] ) {
if ( ! details || ! details . length ) return '' ;
return JSON . stringify ( details ) ;
}
public static unserializeOcrDetails ( s : string ) : RecognizeResultLine [ ] | null {
if ( ! s ) return null ;
try {
const r = JSON . parse ( s ) ;
if ( ! r ) return null ;
if ( ! Array . isArray ( r ) ) throw new Error ( 'OCR details are not valid (not an array' ) ;
return r ;
} catch ( error ) {
error . message = ` Could not unserialized OCR data: ${ error . message } ` ;
throw error ;
}
}
public static async resourceOcrTextsByIds ( ids : string [ ] ) : Promise < ResourceEntity [ ] > {
if ( ! ids . length ) return [ ] ;
ids = unique ( ids ) ;
return this . modelSelectAll ( ` SELECT id, ocr_text FROM resources WHERE id IN (" ${ ids . join ( '","' ) } ") ` ) ;
}
2023-12-24 00:31:21 +02:00
public static async allForNormalization ( updatedTime : number , id : string , limit = 100 , options : LoadOptions = null ) {
const makeQuery = ( useRowValue : boolean ) : SqlQuery = > {
const whereSql = useRowValue ? '(updated_time, id) > (?, ?)' : 'updated_time > ?' ;
const params : any [ ] = [ updatedTime ] ;
if ( useRowValue ) {
params . push ( id ) ;
}
params . push ( ResourceOcrStatus . Done ) ;
params . push ( limit ) ;
return {
sql : `
SELECT $ { this . selectFields ( options ) } FROM resources
WHERE $ { whereSql }
AND ocr_text != ""
AND ocr_status = ?
ORDER BY updated_time ASC , id ASC
LIMIT ?
` ,
params ,
} ;
} ;
// We use a row value in this query, and that's not supported on certain
// Android devices (API level <= 24). So if the query fails, we fallback
// to a non-row value query. Although it may be inaccurate in some cases
// it wouldn't be a critical issue (some OCRed resources may not be part
// of the search engine results) and it means we can keep supporting old
// Android devices.
try {
const r = await this . modelSelectAll ( makeQuery ( true ) ) ;
return r ;
} catch ( error ) {
if ( isSqliteSyntaxError ( error ) ) {
const r = await this . modelSelectAll ( makeQuery ( false ) ) ;
return r ;
} else {
throw error ;
}
}
2023-12-13 21:24:58 +02:00
}
2023-10-24 11:46:33 +02:00
public static async save ( o : ResourceEntity , options : SaveOptions = null ) : Promise < ResourceEntity > {
const resource = { . . . o } ;
2023-12-13 21:24:58 +02:00
const isNew = this . isNew ( o , options ) ;
if ( isNew ) {
2023-10-24 11:46:33 +02:00
const now = Date . now ( ) ;
options = { . . . options , autoTimestamp : false } ;
if ( ! resource . created_time ) resource . created_time = now ;
if ( ! resource . updated_time ) resource . updated_time = now ;
if ( ! resource . blob_updated_time ) resource . blob_updated_time = now ;
}
2023-12-13 21:24:58 +02:00
const output = await super . save ( resource , options ) ;
if ( isNew ) eventManager . emit ( EventName . ResourceCreate ) ;
return output ;
2023-10-24 11:46:33 +02:00
}
2023-10-21 17:07:44 +02:00
2017-06-24 20:51:43 +02:00
}