2023-12-03 12:35:46 +02:00
import { defaultFolderIcon , FolderEntity , FolderIcon , NoteEntity , ResourceEntity } from '../services/database/types' ;
2021-10-14 17:34:53 +02:00
import BaseModel , { DeleteOptions } from '../BaseModel' ;
2024-01-06 19:21:51 +02:00
import { FolderLoadOptions } from './utils/types' ;
2021-01-22 19:41:11 +02:00
import time from '../time' ;
import { _ } from '../locale' ;
2021-01-23 17:51:19 +02:00
import Note from './Note' ;
2021-01-29 20:45:11 +02:00
import Database from '../database' ;
2021-01-23 17:51:19 +02:00
import BaseItem from './BaseItem' ;
2021-05-13 18:57:37 +02:00
import Resource from './Resource' ;
import { isRootSharedFolder } from '../services/share/reducer' ;
2023-07-27 17:05:56 +02:00
import Logger from '@joplin/utils/Logger' ;
2021-10-15 13:24:22 +02:00
import syncDebugLog from '../services/synchronizer/syncDebugLog' ;
2021-11-28 18:46:10 +02:00
import ResourceService from '../services/ResourceService' ;
2023-07-16 18:42:42 +02:00
import { LoadOptions } from './utils/types' ;
2024-03-09 12:33:05 +02:00
import ActionLogger from '../utils/ActionLogger' ;
2024-03-20 13:17:46 +02:00
2024-03-14 20:30:49 +02:00
import { getTrashFolder } from '../services/trash' ;
import getConflictFolderId from './utils/getConflictFolderId' ;
import getTrashFolderId from '../services/trash/getTrashFolderId' ;
2024-03-20 13:17:46 +02:00
import { getCollator } from './utils/getCollator' ;
2020-11-05 18:58:23 +02:00
const { substrWithEllipsis } = require ( '../string-utils.js' ) ;
2017-05-15 21:10:00 +02:00
2021-07-02 18:53:36 +02:00
const logger = Logger . create ( 'models/Folder' ) ;
2022-08-27 14:36:59 +02:00
export interface FolderEntityWithChildren extends FolderEntity {
2021-01-22 19:41:11 +02:00
children? : FolderEntity [ ] ;
}
export default class Folder extends BaseItem {
2023-03-06 16:22:01 +02:00
public static tableName() {
2018-03-09 22:59:12 +02:00
return 'folders' ;
2017-05-15 21:10:00 +02:00
}
2023-03-06 16:22:01 +02:00
public static modelType() {
2017-07-03 21:50:45 +02:00
return BaseModel . TYPE_FOLDER ;
2017-05-18 21:58:01 +02:00
}
2019-07-29 15:43:53 +02:00
2023-03-06 16:22:01 +02:00
public static newFolder ( ) : FolderEntity {
2017-05-15 21:10:00 +02:00
return {
id : null ,
2018-03-09 22:59:12 +02:00
title : '' ,
2019-07-29 15:43:53 +02:00
} ;
2017-05-15 21:10:00 +02:00
}
2023-03-06 16:22:01 +02:00
public static fieldToLabel ( field : string ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-01-22 19:41:11 +02:00
const fieldsToLabels : any = {
2019-03-02 19:35:57 +02:00
title : _ ( 'title' ) ,
last_note_user_updated_time : _ ( 'updated date' ) ,
} ;
return field in fieldsToLabels ? fieldsToLabels [ field ] : field ;
}
2024-03-02 16:25:27 +02:00
public static async notes ( parentId : string , options : LoadOptions = null ) {
options = {
includeConflicts : false ,
. . . options ,
} ;
2020-11-17 13:50:46 +02:00
const where = [ 'parent_id = ?' ] ;
if ( ! options . includeConflicts ) {
where . push ( 'is_conflict = 0' ) ;
}
2024-03-02 16:25:27 +02:00
if ( ! options . includeDeleted ) {
where . push ( 'deleted_time = 0' ) ;
}
return this . modelSelectAll ( ` SELECT ${ this . selectFields ( options ) } FROM notes WHERE ${ where . join ( ' AND ' ) } ` , [ parentId ] ) ;
}
public static async noteIds ( parentId : string , options : LoadOptions = null ) {
const notes = await this . notes ( parentId , {
fields : [ 'id' ] ,
. . . options ,
} ) ;
return notes . map ( n = > n . id ) ;
2017-05-18 22:31:40 +02:00
}
2023-03-06 16:22:01 +02:00
public static async subFolderIds ( parentId : string ) {
2018-05-09 10:53:47 +02:00
const rows = await this . db ( ) . selectAll ( 'SELECT id FROM folders WHERE parent_id = ?' , [ parentId ] ) ;
2021-01-22 19:41:11 +02:00
return rows . map ( ( r : FolderEntity ) = > r . id ) ;
2018-05-09 10:53:47 +02:00
}
2023-03-06 16:22:01 +02:00
public static async noteCount ( parentId : string ) {
2020-03-14 01:46:14 +02:00
const r = await this . db ( ) . selectOne ( 'SELECT count(*) as total FROM notes WHERE is_conflict = 0 AND parent_id = ?' , [ parentId ] ) ;
2017-07-12 22:39:47 +02:00
return r ? r.total : 0 ;
}
2023-03-06 16:22:01 +02:00
public static markNotesAsConflict ( parentId : string ) {
2020-03-14 01:46:14 +02:00
const query = Database . updateQuery ( 'notes' , { is_conflict : 1 } , { parent_id : parentId } ) ;
2017-07-13 20:47:31 +02:00
return this . db ( ) . exec ( query ) ;
}
2024-03-02 16:25:27 +02:00
public static byId ( items : FolderEntity [ ] , id : string ) {
if ( id === getTrashFolderId ( ) ) return getTrashFolder ( ) ;
return super . byId ( items , id ) ;
}
2023-07-23 16:57:55 +02:00
public static async deleteAllByShareId ( shareId : string , deleteOptions : DeleteOptions = null ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2023-07-23 16:57:55 +02:00
const tableNameToClasses : Record < string , any > = {
'folders' : Folder ,
'notes' : Note ,
'resources' : Resource ,
} ;
for ( const tableName of [ 'folders' , 'notes' , 'resources' ] ) {
const ItemClass = tableNameToClasses [ tableName ] ;
const rows = await this . db ( ) . selectAll ( ` SELECT id FROM ${ tableName } WHERE share_id = ? ` , [ shareId ] ) ;
const ids : string [ ] = rows . map ( r = > r . id ) ;
await ItemClass . batchDelete ( ids , deleteOptions ) ;
}
}
2024-03-09 12:33:05 +02:00
public static async delete ( folderId : string , options? : DeleteOptions ) {
2021-05-13 18:57:37 +02:00
options = {
2021-10-14 17:34:53 +02:00
deleteChildren : true ,
2021-05-13 18:57:37 +02:00
. . . options ,
} ;
2017-07-13 20:47:31 +02:00
2024-03-02 16:25:27 +02:00
if ( folderId === getTrashFolderId ( ) ) throw new Error ( 'The trash folder cannot be deleted' ) ;
const toTrash = ! ! options . toTrash ;
2020-03-14 01:46:14 +02:00
const folder = await Folder . load ( folderId ) ;
2017-07-13 20:47:31 +02:00
if ( ! folder ) return ; // noop
2024-03-09 12:33:05 +02:00
const actionLogger = ActionLogger . from ( options . sourceDescription ) ;
actionLogger . addDescription ( ` folder title: ${ JSON . stringify ( folder . title ) } ` ) ;
options . sourceDescription = actionLogger ;
2019-07-29 15:43:53 +02:00
if ( options . deleteChildren ) {
2023-07-23 16:57:55 +02:00
const childrenDeleteOptions : DeleteOptions = {
disableReadOnlyCheck : options.disableReadOnlyCheck ,
2024-03-09 12:33:05 +02:00
sourceDescription : actionLogger ,
2024-03-02 16:25:27 +02:00
deleteChildren : true ,
toTrash ,
2023-07-23 16:57:55 +02:00
} ;
2020-03-14 01:46:14 +02:00
const noteIds = await Folder . noteIds ( folderId ) ;
2023-07-23 16:57:55 +02:00
await Note . batchDelete ( noteIds , childrenDeleteOptions ) ;
2018-05-09 10:53:47 +02:00
2020-03-14 01:46:14 +02:00
const subFolderIds = await Folder . subFolderIds ( folderId ) ;
2018-05-09 10:53:47 +02:00
for ( let i = 0 ; i < subFolderIds . length ; i ++ ) {
2023-07-23 16:57:55 +02:00
await Folder . delete ( subFolderIds [ i ] , childrenDeleteOptions ) ;
2018-05-09 10:53:47 +02:00
}
2017-06-25 09:52:25 +02:00
}
2024-03-02 16:25:27 +02:00
if ( toTrash ) {
const newFolder : FolderEntity = { id : folderId , deleted_time : Date.now ( ) } ;
if ( 'toTrashParentId' in options ) newFolder . parent_id = options . toTrashParentId ;
if ( options . toTrashParentId === newFolder . id ) throw new Error ( 'Parent ID cannot be the same as ID' ) ;
await this . save ( newFolder ) ;
} else {
await super . delete ( folderId , options ) ;
}
2017-06-25 09:52:25 +02:00
this . dispatch ( {
2018-03-09 22:59:12 +02:00
type : 'FOLDER_DELETE' ,
2017-11-08 23:39:07 +02:00
id : folderId ,
2017-05-16 22:42:23 +02:00
} ) ;
}
2023-03-06 16:22:01 +02:00
public static conflictFolderTitle() {
2018-03-09 22:59:12 +02:00
return _ ( 'Conflicts' ) ;
2017-07-15 17:35:40 +02:00
}
2017-06-18 01:49:52 +02:00
2023-03-06 16:22:01 +02:00
public static conflictFolderId() {
2024-03-14 20:30:49 +02:00
return getConflictFolderId ( ) ;
2017-07-15 17:35:40 +02:00
}
2023-03-06 16:22:01 +02:00
public static conflictFolder ( ) : FolderEntity {
2024-03-02 16:25:27 +02:00
const now = Date . now ( ) ;
2017-07-15 17:35:40 +02:00
return {
type_ : this.TYPE_FOLDER ,
id : this.conflictFolderId ( ) ,
2018-05-09 10:53:47 +02:00
parent_id : '' ,
2017-07-15 17:35:40 +02:00
title : this.conflictFolderTitle ( ) ,
2024-03-02 16:25:27 +02:00
updated_time : now ,
user_updated_time : now ,
2023-07-16 18:42:42 +02:00
share_id : '' ,
is_shared : 0 ,
2024-03-29 14:11:15 +02:00
deleted_time : 0 ,
2017-07-15 17:35:40 +02:00
} ;
}
2017-06-25 11:00:54 +02:00
2019-11-11 08:14:56 +02:00
// Calculates note counts for all folders and adds the note_count attribute to each folder
// Note: this only calculates the overall number of nodes for this folder and all its descendants
2024-03-02 16:25:27 +02:00
public static async addNoteCounts ( folders : FolderEntity [ ] , includeCompletedTodos = true ) {
// This is old code so we keep it, but we should never ever add properties to objects from
// the database. Eventually we should refactor this.
interface FolderEntityWithNoteCount extends FolderEntity {
note_count? : number ;
}
const foldersById : Record < string , FolderEntityWithNoteCount > = { } ;
2020-11-14 14:37:18 +02:00
for ( const f of folders ) {
2019-11-11 08:14:56 +02:00
foldersById [ f . id ] = f ;
2020-11-14 14:37:18 +02:00
if ( this . conflictFolderId ( ) === f . id ) {
2024-03-02 16:25:27 +02:00
foldersById [ f . id ] . note_count = await Note . conflictedCount ( ) ;
2020-11-14 14:37:18 +02:00
} else {
2024-03-02 16:25:27 +02:00
foldersById [ f . id ] . note_count = 0 ;
2020-11-14 14:37:18 +02:00
}
}
2024-03-02 16:25:27 +02:00
const where = [
'is_conflict = 0' ,
'notes.deleted_time = 0' ,
] ;
2020-11-14 14:37:18 +02:00
if ( ! includeCompletedTodos ) where . push ( '(notes.is_todo = 0 OR notes.todo_completed = 0)' ) ;
2020-01-18 15:46:04 +02:00
2020-11-14 14:37:18 +02:00
const sql = `
SELECT folders . id as folder_id , count ( notes . parent_id ) as note_count
2020-01-18 15:46:04 +02:00
FROM folders LEFT JOIN notes ON notes . parent_id = folders . id
2020-11-14 14:37:18 +02:00
WHERE $ { where . join ( ' AND ' ) }
GROUP BY folders . id
` ;
2020-01-18 15:46:04 +02:00
2024-03-02 16:25:27 +02:00
interface NoteCount {
folder_id : string ;
note_count : number ;
}
const noteCounts : NoteCount [ ] = await this . db ( ) . selectAll ( sql ) ;
2023-06-30 10:39:21 +02:00
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
2024-03-02 16:25:27 +02:00
noteCounts . forEach ( ( noteCount ) = > {
2019-11-11 08:14:56 +02:00
let parentId = noteCount . folder_id ;
do {
2020-03-14 01:46:14 +02:00
const folder = foldersById [ parentId ] ;
2019-11-12 19:50:48 +02:00
if ( ! folder ) break ; // https://github.com/laurent22/joplin/issues/2079
2019-11-11 08:14:56 +02:00
folder . note_count = ( folder . note_count || 0 ) + noteCount . note_count ;
2020-06-07 13:47:43 +02:00
// Should not happen anymore but just to be safe, add the check below
// https://github.com/laurent22/joplin/issues/3334
if ( folder . id === folder . parent_id ) break ;
2019-11-11 08:14:56 +02:00
parentId = folder . parent_id ;
} while ( parentId ) ;
} ) ;
}
2019-03-02 19:35:57 +02:00
// Folders that contain notes that have been modified recently go on top.
// The remaining folders, that don't contain any notes are sorted by their own user_updated_time
2023-03-06 16:22:01 +02:00
public static async orderByLastModified ( folders : FolderEntity [ ] , dir = 'DESC' ) {
2019-03-02 19:35:57 +02:00
dir = dir . toUpperCase ( ) ;
const sql = 'select parent_id, max(user_updated_time) content_updated_time from notes where parent_id != "" group by parent_id' ;
const rows = await this . db ( ) . selectAll ( sql ) ;
2021-01-22 19:41:11 +02:00
const folderIdToTime : Record < string , number > = { } ;
2019-03-02 19:35:57 +02:00
for ( let i = 0 ; i < rows . length ; i ++ ) {
const row = rows [ i ] ;
folderIdToTime [ row . parent_id ] = row . content_updated_time ;
}
2021-01-22 19:41:11 +02:00
const findFolderParent = ( folderId : string ) = > {
2019-03-02 19:35:57 +02:00
const folder = BaseModel . byId ( folders , folderId ) ;
if ( ! folder ) return null ; // For the rare case of notes that are associated with a no longer existing folder
if ( ! folder . parent_id ) return null ;
for ( let i = 0 ; i < folders . length ; i ++ ) {
if ( folders [ i ] . id === folder . parent_id ) return folders [ i ] ;
}
2019-11-20 20:14:11 +02:00
// In some rare cases, some folders may not have a parent, for example
// if it has not been downloaded via sync yet.
// https://github.com/laurent22/joplin/issues/2088
return null ;
2019-07-29 15:43:53 +02:00
} ;
2019-03-02 19:35:57 +02:00
2021-01-22 19:41:11 +02:00
const applyChildTimeToParent = ( folderId : string ) = > {
2019-03-02 19:35:57 +02:00
const parent = findFolderParent ( folderId ) ;
if ( ! parent ) return ;
2019-03-10 23:16:05 +02:00
if ( folderIdToTime [ parent . id ] && folderIdToTime [ parent . id ] >= folderIdToTime [ folderId ] ) {
// Don't change so that parent has the same time as the last updated child
} else {
folderIdToTime [ parent . id ] = folderIdToTime [ folderId ] ;
}
2019-07-29 15:43:53 +02:00
2019-03-02 19:35:57 +02:00
applyChildTimeToParent ( parent . id ) ;
2019-07-29 15:43:53 +02:00
} ;
2019-03-02 19:35:57 +02:00
2020-03-14 01:46:14 +02:00
for ( const folderId in folderIdToTime ) {
2019-03-02 19:35:57 +02:00
if ( ! folderIdToTime . hasOwnProperty ( folderId ) ) continue ;
applyChildTimeToParent ( folderId ) ;
}
const mod = dir === 'DESC' ? + 1 : - 1 ;
const output = folders . slice ( ) ;
output . sort ( ( a , b ) = > {
const aTime = folderIdToTime [ a . id ] ? folderIdToTime [ a . id ] : a . user_updated_time ;
const bTime = folderIdToTime [ b . id ] ? folderIdToTime [ b . id ] : b . user_updated_time ;
if ( aTime < bTime ) return + 1 * mod ;
if ( aTime > bTime ) return - 1 * mod ;
return 0 ;
} ) ;
return output ;
}
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2024-03-20 13:17:46 +02:00
public static handleTitleNaturalSorting ( items : FolderEntity [ ] , options : any ) {
if ( options . order ? . length > 0 && options . order [ 0 ] . by === 'title' ) {
const collator = getCollator ( ) ;
items . sort ( ( a , b ) = > ( ( options . order [ 0 ] . dir === 'ASC' ) ? 1 : - 1 ) * collator . compare ( a . title , b . title ) ) ;
}
}
2024-01-06 19:21:51 +02:00
public static async all ( options : FolderLoadOptions = null ) {
2024-03-02 16:25:27 +02:00
let output : FolderEntity [ ] = await super . all ( options ) ;
2024-03-20 13:17:46 +02:00
if ( options ) {
this . handleTitleNaturalSorting ( output , options ) ;
}
2024-03-02 16:25:27 +02:00
if ( options && options . includeDeleted === false ) {
output = output . filter ( f = > ! f . deleted_time ) ;
}
if ( options && options . includeTrash ) {
output . push ( getTrashFolder ( ) ) ;
}
2017-07-15 17:35:40 +02:00
if ( options && options . includeConflictFolder ) {
2020-03-14 01:46:14 +02:00
const conflictCount = await Note . conflictedCount ( ) ;
2017-07-15 17:35:40 +02:00
if ( conflictCount ) output . push ( this . conflictFolder ( ) ) ;
}
2024-03-02 16:25:27 +02:00
2017-07-15 17:35:40 +02:00
return output ;
}
2017-06-25 11:00:54 +02:00
2024-03-02 16:25:27 +02:00
public static async childrenIds ( folderId : string , options : LoadOptions = null ) {
options = { . . . options } ;
const where = [ 'parent_id = ?' ] ;
if ( ! options . includeDeleted ) {
where . push ( 'deleted_time = 0' ) ;
}
const folders = await this . db ( ) . selectAll ( ` SELECT id FROM folders WHERE ${ where . join ( ' AND ' ) } ` , [ folderId ] ) ;
2018-06-10 20:15:40 +02:00
2021-01-22 19:41:11 +02:00
let output : string [ ] = [ ] ;
2018-06-10 20:15:40 +02:00
for ( let i = 0 ; i < folders . length ; i ++ ) {
const f = folders [ i ] ;
output . push ( f . id ) ;
2024-03-02 16:25:27 +02:00
const subChildrenIds = await this . childrenIds ( f . id , options ) ;
2018-06-10 20:15:40 +02:00
output = output . concat ( subChildrenIds ) ;
}
return output ;
}
2023-03-06 16:22:01 +02:00
public static async expandTree ( folders : FolderEntity [ ] , parentId : string ) {
2020-03-11 16:20:25 +02:00
const folderPath = await this . folderPath ( folders , parentId ) ;
2020-06-16 00:59:42 +02:00
folderPath . pop ( ) ; // We don't expand the leaft notebook
2020-03-11 16:20:25 +02:00
for ( const folder of folderPath ) {
this . dispatch ( {
type : 'FOLDER_SET_COLLAPSED' ,
id : folder.id ,
collapsed : false ,
} ) ;
}
}
2021-05-13 18:57:37 +02:00
public static async allChildrenFolders ( folderId : string ) : Promise < FolderEntity [ ] > {
const sql = `
WITH RECURSIVE
folders_cte ( id , parent_id , share_id ) AS (
SELECT id , parent_id , share_id
FROM folders
WHERE parent_id = ?
UNION ALL
SELECT folders . id , folders . parent_id , folders . share_id
FROM folders
INNER JOIN folders_cte AS folders_cte ON ( folders . parent_id = folders_cte . id )
)
SELECT id , parent_id , share_id FROM folders_cte ;
` ;
return this . db ( ) . selectAll ( sql , [ folderId ] ) ;
}
2021-12-20 16:47:50 +02:00
public static async rootSharedFolders ( ) : Promise < FolderEntity [ ] > {
2021-05-13 18:57:37 +02:00
return this . db ( ) . selectAll ( 'SELECT id, share_id FROM folders WHERE parent_id = "" AND share_id != ""' ) ;
}
2021-11-03 18:24:40 +02:00
public static async rootShareFoldersByKeyId ( keyId : string ) : Promise < FolderEntity [ ] > {
return this . db ( ) . selectAll ( 'SELECT id, share_id FROM folders WHERE master_key_id = ?' , [ keyId ] ) ;
}
2021-05-13 18:57:37 +02:00
public static async updateFolderShareIds ( ) : Promise < void > {
// Get all the sub-folders of the shared folders, and set the share_id
// property.
const rootFolders = await this . rootSharedFolders ( ) ;
let sharedFolderIds : string [ ] = [ ] ;
2021-07-02 18:53:36 +02:00
const report = {
shareUpdateCount : 0 ,
unshareUpdateCount : 0 ,
} ;
2021-05-13 18:57:37 +02:00
for ( const rootFolder of rootFolders ) {
const children = await this . allChildrenFolders ( rootFolder . id ) ;
2021-07-02 18:53:36 +02:00
report . shareUpdateCount += children . length ;
2021-05-13 18:57:37 +02:00
for ( const child of children ) {
if ( child . share_id !== rootFolder . share_id ) {
await this . save ( {
id : child.id ,
share_id : rootFolder.share_id ,
updated_time : Date.now ( ) ,
} , { autoTimestamp : false } ) ;
}
}
sharedFolderIds . push ( rootFolder . id ) ;
sharedFolderIds = sharedFolderIds . concat ( children . map ( c = > c . id ) ) ;
}
// Now that we've set the share ID on all the sub-folders of the shared
// folders, those that remain should not be shared anymore. For example,
// if they've been moved out of a shared folder.
// await this.unshareItems(ModelType.Folder, sharedFolderIds);
2021-07-02 19:14:49 +02:00
const sql = [ 'SELECT id, parent_id FROM folders WHERE share_id != ""' ] ;
2021-05-13 18:57:37 +02:00
if ( sharedFolderIds . length ) {
sql . push ( ` AND id NOT IN (" ${ sharedFolderIds . join ( '","' ) } ") ` ) ;
}
2021-07-02 19:14:49 +02:00
const foldersToUnshare : FolderEntity [ ] = await this . db ( ) . selectAll ( sql . join ( ' ' ) ) ;
2021-07-02 18:53:36 +02:00
report . unshareUpdateCount += foldersToUnshare . length ;
2021-05-13 18:57:37 +02:00
for ( const item of foldersToUnshare ) {
await this . save ( {
id : item.id ,
share_id : '' ,
updated_time : Date.now ( ) ,
2021-07-02 19:14:49 +02:00
parent_id : item.parent_id ,
2021-05-13 18:57:37 +02:00
} , { autoTimestamp : false } ) ;
}
2021-07-02 18:53:36 +02:00
logger . debug ( 'updateFolderShareIds:' , report ) ;
2021-05-13 18:57:37 +02:00
}
public static async updateNoteShareIds() {
// Find all the notes where the share_id is not the same as the
// parent share_id because we only need to update those.
const rows = await this . db ( ) . selectAll ( `
2021-06-23 15:37:51 +02:00
SELECT notes . id , folders . share_id , notes . parent_id
2021-05-13 18:57:37 +02:00
FROM notes
LEFT JOIN folders ON notes . parent_id = folders . id
WHERE notes . share_id != folders . share_id
` );
2021-07-02 18:53:36 +02:00
logger . debug ( 'updateNoteShareIds: notes to update:' , rows . length ) ;
2021-05-13 18:57:37 +02:00
for ( const row of rows ) {
await Note . save ( {
id : row.id ,
share_id : row.share_id || '' ,
2021-06-23 15:37:51 +02:00
parent_id : row.parent_id ,
2021-05-13 18:57:37 +02:00
updated_time : Date.now ( ) ,
} , { autoTimestamp : false } ) ;
}
}
2021-11-28 18:46:10 +02:00
public static async updateResourceShareIds ( resourceService : ResourceService ) {
// Updating the share_id property of the resources is complex because:
//
// The resource association to the note is done indirectly via the
// ResourceService
//
// And a given resource can appear inside multiple notes. However, for
// sharing we make the assumption that a resource can be part of only
// one share (one-to-one relationship because "share_id" is part of the
// "resources" table), which is usually the case. By copying and pasting
// note content from one note to another it's however possible to have
// the same resource in multiple shares (or in a non-shared and a shared
// folder).
//
// So in this function we take this into account - if a shared resource
// is part of multiple notes, we duplicate that resource so that each
// note has its own instance. When such duplication happens, we need to
// resume the process from the start (thus the loop) so that we deal
// with the right note/resource associations.
2023-12-03 12:35:46 +02:00
interface Row {
id : string ;
share_id : string ;
is_shared : number ;
resource_is_shared : number ;
2024-01-14 14:33:40 +02:00
resource_share_id : string ;
2023-12-03 12:35:46 +02:00
}
2021-11-28 18:46:10 +02:00
for ( let i = 0 ; i < 5 ; i ++ ) {
// Find all resources where share_id is different from parent note
// share_id. Then update share_id on all these resources. Essentially it
// makes it match the resource share_id to the note share_id. At the
// same time we also process the is_shared property.
2023-12-03 12:35:46 +02:00
const rows = ( await this . db ( ) . selectAll ( `
2024-01-14 14:33:40 +02:00
SELECT
r . id ,
n . share_id ,
n . is_shared ,
r . is_shared as resource_is_shared ,
r . share_id as resource_share_id
2021-11-28 18:46:10 +02:00
FROM note_resources nr
LEFT JOIN resources r ON nr . resource_id = r . id
LEFT JOIN notes n ON nr . note_id = n . id
WHERE (
n . share_id != r . share_id
OR n . is_shared != r . is_shared
) AND nr . is_associated = 1
2023-12-03 12:35:46 +02:00
` )) as Row[];
2021-11-28 18:46:10 +02:00
if ( ! rows . length ) return ;
logger . debug ( 'updateResourceShareIds: resources to update:' , rows . length ) ;
const resourceIds = rows . map ( r = > r . id ) ;
2023-12-03 12:35:46 +02:00
interface NoteResourceRow {
2021-11-28 18:46:10 +02:00
resource_id : string ;
note_id : string ;
share_id : string ;
}
2021-05-13 18:57:37 +02:00
2021-11-28 18:46:10 +02:00
// Now we check, for each resource, that it is associated with only
// one note. If it is not, we create duplicate resources so that
// each note has its own separate resource.
2021-07-02 18:53:36 +02:00
2021-11-28 18:46:10 +02:00
const noteResourceAssociations = await this . db ( ) . selectAll ( `
SELECT resource_id , note_id , notes . share_id
FROM note_resources
LEFT JOIN notes ON notes . id = note_resources . note_id
WHERE resource_id IN ( '${resourceIds.join(' \ ',\'' ) } ' )
AND is_associated = 1
2023-12-03 12:35:46 +02:00
` ) as NoteResourceRow[];
2021-11-28 18:46:10 +02:00
2023-12-03 12:35:46 +02:00
const resourceIdToNotes : Record < string , NoteResourceRow [ ] > = { } ;
2021-11-28 18:46:10 +02:00
for ( const r of noteResourceAssociations ) {
if ( ! resourceIdToNotes [ r . resource_id ] ) resourceIdToNotes [ r . resource_id ] = [ ] ;
resourceIdToNotes [ r . resource_id ] . push ( r ) ;
}
let hasCreatedResources = false ;
for ( const [ resourceId , rows ] of Object . entries ( resourceIdToNotes ) ) {
if ( rows . length <= 1 ) continue ;
for ( let i = 0 ; i < rows . length - 1 ; i ++ ) {
const row = rows [ i ] ;
const note : NoteEntity = await Note . load ( row . note_id ) ;
if ( ! note ) continue ; // probably got deleted in the meantime?
const newResource = await Resource . duplicateResource ( resourceId ) ;
logger . info ( ` updateResourceShareIds: Automatically created resource " ${ newResource . id } " to replace resource " ${ resourceId } " because it is shared and duplicate across notes: ` , row ) ;
const regex = new RegExp ( resourceId , 'gi' ) ;
const newBody = note . body . replace ( regex , newResource . id ) ;
await Note . save ( {
id : note.id ,
body : newBody ,
parent_id : note.parent_id ,
updated_time : Date.now ( ) ,
} , {
autoTimestamp : false ,
} ) ;
hasCreatedResources = true ;
}
}
// If we have created resources, we refresh the note/resource
// associations using ResourceService and we resume the process.
// Normally, if the user didn't create any new notes or resources in
// the meantime, the second loop should find that each shared
// resource is associated with only one note.
if ( hasCreatedResources ) {
await resourceService . indexNoteResources ( ) ;
continue ;
} else {
// If all is good, we can set the share_id and is_shared
// property of the resource.
2023-12-03 12:35:46 +02:00
const now = Date . now ( ) ;
2021-11-28 18:46:10 +02:00
for ( const row of rows ) {
2023-12-03 12:35:46 +02:00
const resource : ResourceEntity = {
2021-11-28 18:46:10 +02:00
id : row.id ,
share_id : row.share_id || '' ,
is_shared : row.is_shared ,
2023-12-03 12:35:46 +02:00
updated_time : now ,
} ;
2024-01-14 14:33:40 +02:00
// When a resource becomes published or shared, we set
// `blob_updated_time` to ensure that the resource content
// is uploaded too during the next sync operation.
//
// This is necessary because Joplin Server needs to
// associate `share_id` or `is_shared` with the resource
// content for sharing to work. Otherwise the share
// recipient will only get the resource metadata.
if ( row . is_shared !== row . resource_is_shared || row . share_id !== row . resource_share_id ) {
2023-12-03 12:35:46 +02:00
resource . blob_updated_time = now ;
}
await Resource . save ( resource , { autoTimestamp : false } ) ;
2021-11-28 18:46:10 +02:00
}
return ;
}
2021-05-13 18:57:37 +02:00
}
2021-11-28 18:46:10 +02:00
throw new Error ( 'Failed to update resource share IDs' ) ;
2021-05-13 18:57:37 +02:00
}
2021-11-28 18:46:10 +02:00
public static async updateAllShareIds ( resourceService : ResourceService ) {
2021-05-13 18:57:37 +02:00
await this . updateFolderShareIds ( ) ;
await this . updateNoteShareIds ( ) ;
2021-11-28 18:46:10 +02:00
await this . updateResourceShareIds ( resourceService ) ;
2021-05-13 18:57:37 +02:00
}
2021-06-20 20:29:34 +02:00
// Clear the "share_id" property for the items that are associated with a
// share that no longer exists.
public static async updateNoLongerSharedItems ( activeShareIds : string [ ] ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-06-20 20:29:34 +02:00
const tableNameToClasses : Record < string , any > = {
'folders' : Folder ,
'notes' : Note ,
'resources' : Resource ,
} ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-07-02 18:53:36 +02:00
const report : any = { } ;
2021-06-20 20:29:34 +02:00
for ( const tableName of [ 'folders' , 'notes' , 'resources' ] ) {
const ItemClass = tableNameToClasses [ tableName ] ;
2021-07-02 19:14:49 +02:00
const hasParentId = tableName !== 'resources' ;
const fields = [ 'id' ] ;
if ( hasParentId ) fields . push ( 'parent_id' ) ;
2021-06-20 20:29:34 +02:00
const query = activeShareIds . length ? `
2021-07-02 19:14:49 +02:00
SELECT $ { this . db ( ) . escapeFields ( fields ) } FROM $ { tableName }
2021-07-02 18:53:36 +02:00
WHERE share_id != "" AND share_id NOT IN ( "${activeShareIds.join('" , "')}" )
2021-06-20 20:29:34 +02:00
` : `
2021-07-02 19:14:49 +02:00
SELECT $ { this . db ( ) . escapeFields ( fields ) } FROM $ { tableName }
2021-06-20 20:29:34 +02:00
WHERE share_id != ''
` ;
const rows = await this . db ( ) . selectAll ( query ) ;
2021-07-02 18:53:36 +02:00
report [ tableName ] = rows . length ;
2021-06-20 20:29:34 +02:00
for ( const row of rows ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-07-02 19:14:49 +02:00
const toSave : any = {
2021-06-20 20:29:34 +02:00
id : row.id ,
share_id : '' ,
updated_time : Date.now ( ) ,
2021-07-02 19:14:49 +02:00
} ;
if ( hasParentId ) toSave . parent_id = row . parent_id ;
await ItemClass . save ( toSave , { autoTimestamp : false } ) ;
2021-06-20 20:29:34 +02:00
}
}
2021-07-02 18:53:36 +02:00
logger . debug ( 'updateNoLongerSharedItems:' , report ) ;
2021-06-20 20:29:34 +02:00
}
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2023-03-06 16:22:01 +02:00
public static async allAsTree ( folders : FolderEntity [ ] = null , options : any = null ) {
2024-03-02 16:25:27 +02:00
interface FolderWithNotes extends FolderEntity {
notes? : NoteEntity [ ] ;
}
const all : FolderWithNotes [ ] = folders ? folders : await this . all ( options ) ;
2018-05-26 16:46:57 +02:00
2022-08-29 17:27:26 +02:00
if ( options && options . includeNotes ) {
2022-08-29 17:20:34 +02:00
for ( const folder of all ) {
folder . notes = await Note . previews ( folder . id ) ;
}
}
2018-05-26 16:46:57 +02:00
// https://stackoverflow.com/a/49387427/561309
2021-01-22 19:41:11 +02:00
function getNestedChildren ( models : FolderEntityWithChildren [ ] , parentId : string ) {
2019-07-29 15:43:53 +02:00
const nestedTreeStructure = [ ] ;
const length = models . length ;
2018-05-26 16:46:57 +02:00
2019-07-29 15:43:53 +02:00
for ( let i = 0 ; i < length ; i ++ ) {
const model = models [ i ] ;
2018-05-26 16:46:57 +02:00
2022-07-23 09:31:32 +02:00
if ( model . parent_id === parentId ) {
2019-07-29 15:43:53 +02:00
const children = getNestedChildren ( models , model . id ) ;
2018-05-26 16:46:57 +02:00
2019-07-29 15:43:53 +02:00
if ( children . length > 0 ) {
model . children = children ;
}
2018-05-26 16:46:57 +02:00
2019-07-29 15:43:53 +02:00
nestedTreeStructure . push ( model ) ;
}
}
2018-05-26 16:46:57 +02:00
2019-07-29 15:43:53 +02:00
return nestedTreeStructure ;
2018-05-26 16:46:57 +02:00
}
return getNestedChildren ( all , '' ) ;
}
2023-03-06 16:22:01 +02:00
public static folderPath ( folders : FolderEntity [ ] , folderId : string ) {
2021-01-22 19:41:11 +02:00
const idToFolders : Record < string , FolderEntity > = { } ;
2020-07-28 19:50:34 +02:00
for ( let i = 0 ; i < folders . length ; i ++ ) {
idToFolders [ folders [ i ] . id ] = folders [ i ] ;
}
const path = [ ] ;
while ( folderId ) {
const folder = idToFolders [ folderId ] ;
if ( ! folder ) break ; // Shouldn't happen
path . push ( folder ) ;
folderId = folder . parent_id ;
}
path . reverse ( ) ;
return path ;
2019-04-01 21:43:13 +02:00
}
2023-03-06 16:22:01 +02:00
public static folderPathString ( folders : FolderEntity [ ] , folderId : string , maxTotalLength = 80 ) {
2019-04-01 21:43:13 +02:00
const path = this . folderPath ( folders , folderId ) ;
2019-04-04 09:01:16 +02:00
let currentTotalLength = 0 ;
for ( let i = 0 ; i < path . length ; i ++ ) {
currentTotalLength += path [ i ] . title . length ;
}
let pieceLength = maxTotalLength ;
if ( currentTotalLength > maxTotalLength ) {
pieceLength = maxTotalLength / path . length ;
}
2019-04-01 21:43:13 +02:00
const output = [ ] ;
for ( let i = 0 ; i < path . length ; i ++ ) {
2019-04-04 09:01:16 +02:00
output . push ( substrWithEllipsis ( path [ i ] . title , 0 , pieceLength ) ) ;
2019-04-01 21:43:13 +02:00
}
2019-04-04 09:01:16 +02:00
2019-04-01 21:43:13 +02:00
return output . join ( ' / ' ) ;
}
2023-03-06 16:22:01 +02:00
public static buildTree ( folders : FolderEntity [ ] ) : FolderEntityWithChildren [ ] {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-01-22 19:41:11 +02:00
const idToFolders : Record < string , any > = { } ;
2018-12-16 18:18:24 +02:00
for ( let i = 0 ; i < folders . length ; i ++ ) {
2023-06-01 13:02:36 +02:00
idToFolders [ folders [ i ] . id ] = { . . . folders [ i ] } ;
2018-12-16 18:18:24 +02:00
idToFolders [ folders [ i ] . id ] . children = [ ] ;
}
const rootFolders = [ ] ;
2020-03-14 01:46:14 +02:00
for ( const folderId in idToFolders ) {
2018-12-16 18:18:24 +02:00
if ( ! idToFolders . hasOwnProperty ( folderId ) ) continue ;
const folder = idToFolders [ folderId ] ;
if ( ! folder . parent_id ) {
rootFolders . push ( folder ) ;
} else {
2018-12-31 18:33:20 +02:00
if ( ! idToFolders [ folder . parent_id ] ) {
2024-02-26 12:16:23 +02:00
// It means the notebook is referring a folder that doesn't exist. In theory it shouldn't happen
2018-12-31 18:33:20 +02:00
// but sometimes does - https://github.com/laurent22/joplin/issues/1068#issuecomment-450594708
rootFolders . push ( folder ) ;
} else {
idToFolders [ folder . parent_id ] . children . push ( folder ) ;
}
2018-12-16 18:18:24 +02:00
}
}
return rootFolders ;
}
2023-03-06 16:22:01 +02:00
public static async sortFolderTree ( folders : FolderEntityWithChildren [ ] = null ) {
2020-05-09 17:19:30 +02:00
const output = folders ? folders : await this . allAsTree ( ) ;
2021-01-22 19:41:11 +02:00
const sortFoldersAlphabetically = ( folders : FolderEntityWithChildren [ ] ) = > {
2024-03-20 13:17:46 +02:00
const collator = getCollator ( ) ;
2021-01-22 19:41:11 +02:00
folders . sort ( ( a : FolderEntityWithChildren , b : FolderEntityWithChildren ) = > {
if ( a . parent_id === b . parent_id ) {
2024-03-20 13:17:46 +02:00
return collator . compare ( a . title , b . title ) ;
2020-05-09 17:19:30 +02:00
}
2021-01-22 19:41:11 +02:00
return 0 ;
2020-05-09 17:19:30 +02:00
} ) ;
return folders ;
} ;
2021-01-22 19:41:11 +02:00
const sortFolders = ( folders : FolderEntityWithChildren [ ] ) = > {
2020-05-09 17:19:30 +02:00
for ( let i = 0 ; i < folders . length ; i ++ ) {
const folder = folders [ i ] ;
if ( folder . children ) {
folder . children = sortFoldersAlphabetically ( folder . children ) ;
sortFolders ( folder . children ) ;
}
}
return folders ;
} ;
sortFolders ( sortFoldersAlphabetically ( output ) ) ;
return output ;
}
2024-03-02 16:25:27 +02:00
public static async loadByTitleAndParent ( title : string , parentId : string , options : LoadOptions = null ) : Promise < FolderEntity > {
return await this . modelSelectOne ( ` SELECT ${ this . selectFields ( options ) } FROM folders WHERE title = ? and parent_id = ? ` , [ title , parentId ] ) ;
}
2023-07-16 18:42:42 +02:00
public static load ( id : string , options : LoadOptions = null ) : Promise < FolderEntity > {
2022-07-23 09:31:32 +02:00
if ( id === this . conflictFolderId ( ) ) return Promise . resolve ( this . conflictFolder ( ) ) ;
2024-03-02 16:25:27 +02:00
if ( id === getTrashFolderId ( ) ) return Promise . resolve ( getTrashFolder ( ) ) ;
2023-07-16 18:42:42 +02:00
return super . load ( id , options ) ;
2017-06-19 20:58:49 +02:00
}
2023-03-06 16:22:01 +02:00
public static defaultFolder() {
2018-03-09 22:59:12 +02:00
return this . modelSelectOne ( 'SELECT * FROM folders ORDER BY created_time DESC LIMIT 1' ) ;
2017-06-25 01:19:11 +02:00
}
2023-03-06 16:22:01 +02:00
public static async canNestUnder ( folderId : string , targetFolderId : string ) {
2018-05-09 10:53:47 +02:00
if ( folderId === targetFolderId ) return false ;
2021-05-13 18:57:37 +02:00
const folder = await Folder . load ( folderId ) ;
if ( isRootSharedFolder ( folder ) ) return false ;
2018-05-09 10:53:47 +02:00
const conflictFolderId = Folder . conflictFolderId ( ) ;
2022-07-23 09:31:32 +02:00
if ( folderId === conflictFolderId || targetFolderId === conflictFolderId ) return false ;
2018-05-09 10:53:47 +02:00
if ( ! targetFolderId ) return true ;
while ( true ) {
2020-03-14 01:46:14 +02:00
const folder = await Folder . load ( targetFolderId ) ;
2018-05-09 10:53:47 +02:00
if ( ! folder . parent_id ) break ;
if ( folder . parent_id === folderId ) return false ;
targetFolderId = folder . parent_id ;
}
return true ;
}
2023-03-06 16:22:01 +02:00
public static async moveToFolder ( folderId : string , targetFolderId : string ) {
2018-05-09 10:53:47 +02:00
if ( ! ( await this . canNestUnder ( folderId , targetFolderId ) ) ) throw new Error ( _ ( 'Cannot move notebook to this location' ) ) ;
// When moving a note to a different folder, the user timestamp is not updated.
// However updated_time is updated so that the note can be synced later on.
const modifiedFolder = {
id : folderId ,
parent_id : targetFolderId ,
updated_time : time.unixMs ( ) ,
} ;
return Folder . save ( modifiedFolder , { autoTimestamp : false } ) ;
}
2017-07-15 17:35:40 +02:00
// These "duplicateCheck" and "reservedTitleCheck" should only be done when a user is
// manually creating a folder. They shouldn't be done for example when the folders
2019-07-29 15:43:53 +02:00
// are being synced to avoid any strange side-effects. Technically it's possible to
2017-07-17 21:19:01 +02:00
// have folders and notes with duplicate titles (or no title), or with reserved words.
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2023-03-06 16:22:01 +02:00
public static async save ( o : FolderEntity , options : any = null ) {
2017-07-17 21:19:01 +02:00
if ( ! options ) options = { } ;
if ( options . userSideValidation === true ) {
2018-03-09 22:59:12 +02:00
if ( ! ( 'duplicateCheck' in options ) ) options . duplicateCheck = true ;
if ( ! ( 'reservedTitleCheck' in options ) ) options . reservedTitleCheck = true ;
2019-07-29 15:43:53 +02:00
if ( ! ( 'stripLeftSlashes' in options ) ) options . stripLeftSlashes = true ;
2020-06-07 13:47:43 +02:00
if ( o . id && o . parent_id && o . id === o . parent_id ) {
throw new Error ( 'Parent ID cannot be the same as ID' ) ;
}
2017-07-17 21:19:01 +02:00
}
if ( options . stripLeftSlashes === true && o . title ) {
2022-07-23 09:31:32 +02:00
while ( o . title . length && ( o . title [ 0 ] === '/' || o . title [ 0 ] === '\\' ) ) {
2017-07-17 21:19:01 +02:00
o . title = o . title . substr ( 1 ) ;
}
}
2018-09-13 21:53:31 +02:00
// We allow folders with duplicate titles so that folders with the same title can exist under different parent folder. For example:
//
// PHP
// Code samples
// Doc
// Java
// My project
// Doc
// if (options.duplicateCheck === true && o.title) {
// let existingFolder = await Folder.loadByTitle(o.title);
// if (existingFolder && existingFolder.id != o.id) throw new Error(_('A notebook with this title already exists: "%s"', o.title));
// }
2017-07-03 20:58:01 +02:00
2017-07-17 21:19:01 +02:00
if ( options . reservedTitleCheck === true && o . title ) {
2022-07-23 09:31:32 +02:00
if ( o . title === Folder . conflictFolderTitle ( ) ) throw new Error ( _ ( 'Notebooks cannot be named "%s", which is a reserved title.' , o . title ) ) ;
2017-07-15 17:35:40 +02:00
}
2021-10-15 13:24:22 +02:00
syncDebugLog . info ( 'Folder Save:' , o ) ;
2023-07-23 16:57:55 +02:00
let savedFolder : FolderEntity = await super . save ( o , options ) ;
// Ensures that any folder added to the state has all the required
// properties, in particular "share_id" and "parent_id', which are
// required in various parts of the code.
2024-03-02 16:25:27 +02:00
if ( ! ( 'share_id' in savedFolder ) || ! ( 'parent_id' in savedFolder ) || ! ( 'deleted_time' in savedFolder ) ) {
2023-07-23 16:57:55 +02:00
savedFolder = await this . load ( savedFolder . id ) ;
}
2023-07-16 18:42:42 +02:00
this . dispatch ( {
type : 'FOLDER_UPDATE_ONE' ,
item : savedFolder ,
2017-05-18 22:31:40 +02:00
} ) ;
2023-07-16 18:42:42 +02:00
return savedFolder ;
2017-05-18 22:31:40 +02:00
}
2021-11-15 19:19:51 +02:00
2024-03-02 16:25:27 +02:00
public static async trashItemsOlderThan ( ttl : number ) {
const cutOffTime = Date . now ( ) - ttl ;
const getItemIds = async ( table : string , cutOffTime : number ) : Promise < string [ ] > = > {
const items = await this . db ( ) . selectAll ( ` SELECT id from ${ table } WHERE deleted_time > 0 AND deleted_time < ? ` , [ cutOffTime ] ) ;
return items . map ( i = > i . id ) ;
} ;
return {
noteIds : await getItemIds ( 'notes' , cutOffTime ) ,
folderIds : await getItemIds ( 'folders' , cutOffTime ) ,
} ;
}
2021-11-15 19:19:51 +02:00
public static serializeIcon ( icon : FolderIcon ) : string {
return icon ? JSON . stringify ( icon ) : '' ;
}
public static unserializeIcon ( icon : string ) : FolderIcon {
2022-02-06 18:42:00 +02:00
if ( ! icon ) return null ;
return {
. . . defaultFolderIcon ( ) ,
. . . JSON . parse ( icon ) ,
} ;
2021-11-15 19:19:51 +02:00
}
2022-10-11 13:46:40 +02:00
public static shouldShowFolderIcons ( folders : FolderEntity [ ] ) {
// If at least one of the folder has an icon, then we display icons for all
// folders (those without one will get the default icon). This is so that
// visual alignment is correct for all folders, otherwise the folder tree
// looks messy.
return ! ! folders . find ( f = > ! ! f . icon ) ;
}
2024-03-20 12:53:36 +02:00
public static getRealFolders ( folders : FolderEntity [ ] ) {
// returns all folders other than trash folder and deleted folders
const trashFolderId = getTrashFolderId ( ) ;
return folders . filter ( ( folder ) = > folder . id !== trashFolderId && folder . deleted_time === 0 ) ;
}
2024-03-11 17:22:26 +02:00
public static atLeastOneRealFolderExists ( folders : FolderEntity [ ] ) {
// returns true if at least one folder exists other than trash folder and deleted folders
2024-03-20 12:53:36 +02:00
return this . getRealFolders ( folders ) . length > 0 ;
2024-03-11 17:22:26 +02:00
}
2017-05-15 21:10:00 +02:00
}