diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js
index 4126e580c..4917db3bf 100644
--- a/browser/components/CodeEditor.js
+++ b/browser/components/CodeEditor.js
@@ -70,7 +70,9 @@ export default class CodeEditor extends React.Component {
storageKey,
noteKey
} = this.props
- debouncedDeletionOfAttachments(this.editor.getValue(), storageKey, noteKey)
+ if (this.props.deleteUnusedAttachments === true) {
+ debouncedDeletionOfAttachments(this.editor.getValue(), storageKey, noteKey)
+ }
}
this.pasteHandler = (editor, e) => {
e.preventDefault()
@@ -632,6 +634,9 @@ export default class CodeEditor extends React.Component {
this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'})
}
}
+ if (prevProps.deleteUnusedAttachments !== this.props.deleteUnusedAttachments) {
+ this.editor.setOption('deleteUnusedAttachments', this.props.deleteUnusedAttachments)
+ }
if (needRefresh) {
this.editor.refresh()
@@ -1204,7 +1209,8 @@ CodeEditor.propTypes = {
autoDetect: PropTypes.bool,
spellCheck: PropTypes.bool,
enableMarkdownLint: PropTypes.bool,
- customMarkdownLintConfig: PropTypes.string
+ customMarkdownLintConfig: PropTypes.string,
+ deleteUnusedAttachments: PropTypes.bool
}
CodeEditor.defaultProps = {
@@ -1219,5 +1225,6 @@ CodeEditor.defaultProps = {
spellCheck: false,
enableMarkdownLint: DEFAULT_CONFIG.editor.enableMarkdownLint,
customMarkdownLintConfig: DEFAULT_CONFIG.editor.customMarkdownLintConfig,
- prettierConfig: DEFAULT_CONFIG.editor.prettierConfig
+ prettierConfig: DEFAULT_CONFIG.editor.prettierConfig,
+ deleteUnusedAttachments: DEFAULT_CONFIG.editor.deleteUnusedAttachments
}
diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js
index c38aa99b4..cd885fd93 100644
--- a/browser/components/MarkdownEditor.js
+++ b/browser/components/MarkdownEditor.js
@@ -324,6 +324,7 @@ class MarkdownEditor extends React.Component {
enableMarkdownLint={config.editor.enableMarkdownLint}
customMarkdownLintConfig={config.editor.customMarkdownLintConfig}
prettierConfig={config.editor.prettierConfig}
+ deleteUnusedAttachments={config.editor.deleteUnusedAttachments}
/>
this.handleMouseDown(e)} >
diff --git a/browser/lib/utils.js b/browser/lib/utils.js
index 4bcc96989..9f6f14258 100644
--- a/browser/lib/utils.js
+++ b/browser/lib/utils.js
@@ -136,9 +136,24 @@ export function isMarkdownTitleURL (str) {
return /(^#{1,6}\s)(?:\w+:|^)\/\/(?:[^\s\.]+\.\S{2}|localhost[\:?\d]*)/.test(str)
}
+export function humanFileSize (bytes) {
+ const threshold = 1000
+ if (Math.abs(bytes) < threshold) {
+ return bytes + ' B'
+ }
+ var units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+ var u = -1
+ do {
+ bytes /= threshold
+ ++u
+ } while (Math.abs(bytes) >= threshold && u < units.length - 1)
+ return bytes.toFixed(1) + ' ' + units[u]
+}
+
export default {
lastFindInArray,
escapeHtmlCharacters,
isObjectEqual,
- isMarkdownTitleURL
+ isMarkdownTitleURL,
+ humanFileSize
}
diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js
index 7fba00d88..b753c5159 100644
--- a/browser/main/lib/ConfigManager.js
+++ b/browser/main/lib/ConfigManager.js
@@ -75,8 +75,8 @@ export const DEFAULT_CONFIG = {
"tabWidth": 4,
"semi": false,
"singleQuote": true
- }`
-
+ }`,
+ deleteUnusedAttachments: true
},
preview: {
fontSize: '14',
diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js
index 9419435c1..971ae8128 100644
--- a/browser/main/lib/dataApi/attachmentManagement.js
+++ b/browser/main/lib/dataApi/attachmentManagement.js
@@ -624,6 +624,76 @@ function deleteAttachmentsNotPresentInNote (markdownContent, storageKey, noteKey
}
}
+/**
+ * @description Get all existing attachments related to a specific note
+ including their status (in use or not) and their path. Return null if there're no attachment related to note or specified parametters are invalid
+ * @param markdownContent markdownContent of the current note
+ * @param storageKey StorageKey of the current note
+ * @param noteKey NoteKey of the currentNote
+ * @return {Promise
>} Promise returning the
+ list of attachments with their properties */
+function getAttachmentsPathAndStatus (markdownContent, storageKey, noteKey) {
+ if (storageKey == null || noteKey == null || markdownContent == null) {
+ return null
+ }
+ const targetStorage = findStorage.findStorage(storageKey)
+ const attachmentFolder = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey)
+ const attachmentsInNote = getAttachmentsInMarkdownContent(markdownContent)
+ const attachmentsInNoteOnlyFileNames = []
+ if (attachmentsInNote) {
+ for (let i = 0; i < attachmentsInNote.length; i++) {
+ attachmentsInNoteOnlyFileNames.push(attachmentsInNote[i].replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + noteKey + escapeStringRegexp(path.sep), 'g'), ''))
+ }
+ }
+ if (fs.existsSync(attachmentFolder)) {
+ return new Promise((resolve, reject) => {
+ fs.readdir(attachmentFolder, (err, files) => {
+ if (err) {
+ console.error('Error reading directory "' + attachmentFolder + '". Error:')
+ console.error(err)
+ reject(err)
+ return
+ }
+ const attachments = []
+ for (const file of files) {
+ const absolutePathOfFile = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey, file)
+ if (!attachmentsInNoteOnlyFileNames.includes(file)) {
+ attachments.push({ path: absolutePathOfFile, isInUse: false })
+ } else {
+ attachments.push({ path: absolutePathOfFile, isInUse: true })
+ }
+ }
+ resolve(attachments)
+ })
+ })
+ } else {
+ return null
+ }
+}
+
+/**
+ * @description Remove all specified attachment paths
+ * @param attachments attachment paths
+ * @return {Promise} Promise after all attachments are removed */
+function removeAttachmentsByPaths (attachments) {
+ const promises = []
+ for (const attachment of attachments) {
+ const promise = new Promise((resolve, reject) => {
+ fs.unlink(attachment, (err) => {
+ if (err) {
+ console.error('Could not delete "%s"', attachment)
+ console.error(err)
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+ promises.push(promise)
+ }
+ return Promise.all(promises)
+}
+
/**
* Clones the attachments of a given note.
* Copies the attachments to their new destination and updates the content of the new note so that the attachment-links again point to the correct destination.
@@ -726,8 +796,10 @@ module.exports = {
getAbsolutePathsOfAttachmentsInContent,
importAttachments,
removeStorageAndNoteReferences,
+ removeAttachmentsByPaths,
deleteAttachmentFolder,
deleteAttachmentsNotPresentInNote,
+ getAttachmentsPathAndStatus,
moveAttachments,
cloneAttachments,
isAttachmentLink,
diff --git a/browser/main/modals/PreferencesModal/StoragesTab.js b/browser/main/modals/PreferencesModal/StoragesTab.js
index 046b24e61..e84fa88cf 100644
--- a/browser/main/modals/PreferencesModal/StoragesTab.js
+++ b/browser/main/modals/PreferencesModal/StoragesTab.js
@@ -3,8 +3,11 @@ import React from 'react'
import CSSModules from 'browser/lib/CSSModules'
import styles from './StoragesTab.styl'
import dataApi from 'browser/main/lib/dataApi'
+import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement'
import StorageItem from './StorageItem'
import i18n from 'browser/lib/i18n'
+import { humanFileSize } from 'browser/lib/utils'
+import fs from 'fs'
const electron = require('electron')
const { shell, remote } = electron
@@ -35,8 +38,29 @@ class StoragesTab extends React.Component {
name: 'Unnamed',
type: 'FILESYSTEM',
path: ''
- }
+ },
+ attachments: []
}
+ this.loadAttachmentStorage()
+ }
+
+ loadAttachmentStorage () {
+ const promises = []
+ this.props.data.noteMap.map(note => {
+ const promise = attachmentManagement.getAttachmentsPathAndStatus(
+ note.content,
+ note.storage,
+ note.key
+ )
+ if (promise) promises.push(promise)
+ })
+
+ Promise.all(promises)
+ .then(data => {
+ const result = data.reduce((acc, curr) => acc.concat(curr), [])
+ this.setState({attachments: result})
+ })
+ .catch(console.error)
}
handleAddStorageButton (e) {
@@ -57,8 +81,39 @@ class StoragesTab extends React.Component {
e.preventDefault()
}
+ handleRemoveUnusedAttachments (attachments) {
+ attachmentManagement.removeAttachmentsByPaths(attachments)
+ .then(() => this.loadAttachmentStorage())
+ .catch(console.error)
+ }
+
renderList () {
const { data, boundingBox } = this.props
+ const { attachments } = this.state
+
+ const unusedAttachments = attachments.filter(attachment => !attachment.isInUse)
+ const inUseAttachments = attachments.filter(attachment => attachment.isInUse)
+
+ const totalUnusedAttachments = unusedAttachments.length
+ const totalInuseAttachments = inUseAttachments.length
+ const totalAttachments = totalUnusedAttachments + totalInuseAttachments
+
+ const totalUnusedAttachmentsSize = unusedAttachments
+ .reduce((acc, curr) => {
+ const stats = fs.statSync(curr.path)
+ const fileSizeInBytes = stats.size
+ return acc + fileSizeInBytes
+ }, 0)
+ const totalInuseAttachmentsSize = inUseAttachments
+ .reduce((acc, curr) => {
+ const stats = fs.statSync(curr.path)
+ const fileSizeInBytes = stats.size
+ return acc + fileSizeInBytes
+ }, 0)
+ const totalAttachmentsSize = totalUnusedAttachmentsSize + totalInuseAttachmentsSize
+
+ const unusedAttachmentPaths = unusedAttachments
+ .reduce((acc, curr) => acc.concat(curr.path), [])
if (!boundingBox) { return null }
const storageList = data.storageMap.map((storage) => {
@@ -82,6 +137,20 @@ class StoragesTab extends React.Component {
{i18n.__('Add Storage Location')}
+ {i18n.__('Attachment storage')}
+
+ Unused attachments size: {humanFileSize(totalUnusedAttachmentsSize)} ({totalUnusedAttachments} items)
+
+
+ In use attachments size: {humanFileSize(totalInuseAttachmentsSize)} ({totalInuseAttachments} items)
+
+
+ Total attachments size: {humanFileSize(totalAttachmentsSize)} ({totalAttachments} items)
+
+
)
}
diff --git a/browser/main/modals/PreferencesModal/StoragesTab.styl b/browser/main/modals/PreferencesModal/StoragesTab.styl
index b63cc85ed..fbfa89e6b 100644
--- a/browser/main/modals/PreferencesModal/StoragesTab.styl
+++ b/browser/main/modals/PreferencesModal/StoragesTab.styl
@@ -33,6 +33,17 @@
colorDefaultButton()
font-size $tab--button-font-size
border-radius 2px
+.list-attachment-label
+ margin-bottom 10px
+ color $ui-text-color
+.list-attachement-clear-button
+ height 30px
+ border none
+ border-top-right-radius 2px
+ border-bottom-right-radius 2px
+ colorPrimaryButton()
+ vertical-align middle
+ padding 0 20px
.addStorage
margin-bottom 15px
@@ -154,8 +165,8 @@ body[data-theme="dark"]
.addStorage-body-control-cancelButton
colorDarkDefaultButton()
border-color $ui-dark-borderColor
-
-
+ .list-attachement-clear-button
+ colorDarkPrimaryButton()
body[data-theme="solarized-dark"]
.root
@@ -194,6 +205,8 @@ body[data-theme="solarized-dark"]
.addStorage-body-control-cancelButton
colorDarkDefaultButton()
border-color $ui-solarized-dark-borderColor
+ .list-attachement-clear-button
+ colorSolarizedDarkPrimaryButton()
body[data-theme="monokai"]
.root
@@ -232,6 +245,8 @@ body[data-theme="monokai"]
.addStorage-body-control-cancelButton
colorDarkDefaultButton()
border-color $ui-monokai-borderColor
+ .list-attachement-clear-button
+ colorMonokaiPrimaryButton()
body[data-theme="dracula"]
.root
@@ -269,4 +284,6 @@ body[data-theme="dracula"]
colorDarkPrimaryButton()
.addStorage-body-control-cancelButton
colorDarkDefaultButton()
- border-color $ui-dracula-borderColor
\ No newline at end of file
+ border-color $ui-dracula-borderColor
+ .list-attachement-clear-button
+ colorDraculaPrimaryButton()
\ No newline at end of file
diff --git a/browser/main/modals/PreferencesModal/UiTab.js b/browser/main/modals/PreferencesModal/UiTab.js
index 9014de422..329dbfa42 100644
--- a/browser/main/modals/PreferencesModal/UiTab.js
+++ b/browser/main/modals/PreferencesModal/UiTab.js
@@ -111,8 +111,8 @@ class UiTab extends React.Component {
enableSmartPaste: this.refs.enableSmartPaste.checked,
enableMarkdownLint: this.refs.enableMarkdownLint.checked,
customMarkdownLintConfig: this.customMarkdownLintConfigCM.getCodeMirror().getValue(),
- prettierConfig: this.prettierConfigCM.getCodeMirror().getValue()
-
+ prettierConfig: this.prettierConfigCM.getCodeMirror().getValue(),
+ deleteUnusedAttachments: this.refs.deleteUnusedAttachments.checked
},
preview: {
fontSize: this.refs.previewFontSize.value,
@@ -618,6 +618,16 @@ class UiTab extends React.Component {
{i18n.__('Enable spellcheck - Experimental feature!! :)')}
+
+
+
diff --git a/tests/dataApi/attachmentManagement.test.js b/tests/dataApi/attachmentManagement.test.js
index 0f420bccf..13dcedcad 100644
--- a/tests/dataApi/attachmentManagement.test.js
+++ b/tests/dataApi/attachmentManagement.test.js
@@ -578,6 +578,72 @@ it('should test that deleteAttachmentsNotPresentInNote does nothing if noteKey,
expect(fs.unlink).not.toHaveBeenCalled()
})
+it('should test that getAttachmentsPathAndStatus return null if noteKey, storageKey or noteContent was undefined', function () {
+ const noteKey = undefined
+ const storageKey = undefined
+ const markdownContent = ''
+
+ const result = systemUnderTest.getAttachmentsPathAndStatus(markdownContent, storageKey, noteKey)
+ expect(result).toBeNull()
+})
+
+it('should test that getAttachmentsPathAndStatus return null if noteKey, storageKey or noteContent was null', function () {
+ const noteKey = null
+ const storageKey = null
+ const markdownContent = ''
+
+ const result = systemUnderTest.getAttachmentsPathAndStatus(markdownContent, storageKey, noteKey)
+ expect(result).toBeNull()
+})
+
+it('should test that getAttachmentsPathAndStatus return the correct path and status for attachments', async function () {
+ const dummyStorage = {path: 'dummyStoragePath'}
+ const noteKey = 'noteKey'
+ const storageKey = 'storageKey'
+ const markdownContent =
+ 'Test input' +
+ ' \n'
+ const dummyFilesInFolder = ['file1.txt', 'file2.pdf', 'file3.jpg']
+
+ findStorage.findStorage = jest.fn(() => dummyStorage)
+ fs.existsSync = jest.fn(() => true)
+ fs.readdir = jest.fn((paht, callback) => callback(undefined, dummyFilesInFolder))
+ fs.unlink = jest.fn()
+
+ const targetStorage = findStorage.findStorage(storageKey)
+
+ const attachments = await systemUnderTest.getAttachmentsPathAndStatus(markdownContent, storageKey, noteKey)
+ expect(attachments.length).toBe(3)
+ expect(attachments[0].isInUse).toBe(false)
+ expect(attachments[1].isInUse).toBe(true)
+ expect(attachments[2].isInUse).toBe(false)
+
+ expect(attachments[0].path).toBe(
+ path.join(
+ targetStorage.path,
+ systemUnderTest.DESTINATION_FOLDER,
+ noteKey,
+ dummyFilesInFolder[0]
+ )
+ )
+ expect(attachments[1].path).toBe(
+ path.join(
+ targetStorage.path,
+ systemUnderTest.DESTINATION_FOLDER,
+ noteKey,
+ dummyFilesInFolder[1]
+ )
+ )
+ expect(attachments[2].path).toBe(
+ path.join(
+ targetStorage.path,
+ systemUnderTest.DESTINATION_FOLDER,
+ noteKey,
+ dummyFilesInFolder[2]
+ )
+ )
+})
+
it('should test that moveAttachments moves attachments only if the source folder existed', function () {
fse.existsSync = jest.fn(() => false)
fse.moveSync = jest.fn()