diff --git a/package.json b/package.json index 3b8e07a8..b5fae780 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,8 @@ "vue-electron": "^1.0.6", "vue-router": "^3.5.3", "vuex": "^3.6.2", - "webfontloader": "^1.6.28" + "webfontloader": "^1.6.28", + "web3.storage": "latest" }, "devDependencies": { "@babel/core": "^7.17.9", diff --git a/src/main/app/windowManager.js b/src/main/app/windowManager.js index 61e6129c..acf5d398 100644 --- a/src/main/app/windowManager.js +++ b/src/main/app/windowManager.js @@ -3,6 +3,7 @@ import EventEmitter from 'events' import log from 'electron-log' import Watcher, { WATCHER_STABILITY_THRESHOLD, WATCHER_STABILITY_POLL_INTERVAL } from '../filesystem/watcher' import { WindowType } from '../windows/base' +import { Web3Storage, getFilesFromPath } from 'web3.storage' class WindowActivityList { constructor () { @@ -424,6 +425,16 @@ class WindowManager extends EventEmitter { this._watcher.ignoreChangedEvent(windowId, pathname, duration) }) + ipcMain.on('add-file-to-ipfs', async (pathname) => { + // A changed event is emitted earliest after the stability threshold. + const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6ZXRocjoweDU4ZDc1ZjYzN2Y5NDc2YzVkQmU1OGIxNzEyN0Q1MGU0NDgxMzUzQjQiLCJpc3MiOiJ3ZWIzLXN0b3JhZ2UiLCJpYXQiOjE2NjE0MDU2Mzc2MDQsIm5hbWUiOiJ4aW5taW5zdSJ9.sb1ATMTwOtsquSn6kTWQylCRUZjVDWrGUq5o6sLHlis' + const storage = new Web3Storage({ token }) + + const files = await getFilesFromPath(pathname) + const cid = await storage.put(files) + console.log('Content added with CID:', cid) + }) + ipcMain.on('window-close-by-id', id => { this.forceCloseById(id) }) diff --git a/src/main/filesystem/index.js b/src/main/filesystem/index.js index 870738af..22d77dde 100644 --- a/src/main/filesystem/index.js +++ b/src/main/filesystem/index.js @@ -30,3 +30,12 @@ export const writeFile = (pathname, content, extension, options = 'utf-8') => { return fs.outputFile(pathname, content, options) } + +export const writeFileToIpfs = async (pathname, content, extension, options = 'utf-8') => { + if (!pathname) { + return Promise.reject(new Error('[ERROR] Cannot save file without path.')) + } + pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}` + + return fs.outputFile(pathname, content, options) +} diff --git a/src/main/filesystem/markdown.js b/src/main/filesystem/markdown.js index 46a01ce3..9d5ada10 100644 --- a/src/main/filesystem/markdown.js +++ b/src/main/filesystem/markdown.js @@ -5,7 +5,7 @@ import iconv from 'iconv-lite' import { LINE_ENDING_REG, LF_LINE_ENDING_REG, CRLF_LINE_ENDING_REG } from '../config' import { isDirectory2 } from 'common/filesystem' import { isMarkdownFile } from 'common/filesystem/paths' -import { normalizeAndResolvePath, writeFile } from '../filesystem' +import { normalizeAndResolvePath, writeFile, writeFileToIpfs } from '../filesystem' import { guessEncoding } from './encoding' const getLineEnding = lineEnding => { @@ -67,6 +67,28 @@ export const writeMarkdownFile = (pathname, content, options) => { return writeFile(pathname, buffer, extension, undefined) } +/** + * Write the content file to ipfs. + * + * @param {string} pathname The path to the file. + * @param {string} content The buffer to save. + * @param {IMarkdownDocumentOptions} options The markdown document options + */ +export const writeMarkdownFileToIpfs = (pathname, content, options) => { + const { adjustLineEndingOnSave, lineEnding } = options + const { encoding, isBom } = options.encoding + const extension = path.extname(pathname) || '.md' + + if (adjustLineEndingOnSave) { + content = convertLineEndings(content, lineEnding) + } + + const buffer = iconv.encode(content, encoding, { addBOM: isBom }) + + // TODO(@fxha): "safeSaveDocuments" using temporary file and rename syscall. + return writeFileToIpfs(pathname, buffer, extension, undefined) +} + /** * Reads the contents of a markdown file. * diff --git a/src/main/menu/actions/file.js b/src/main/menu/actions/file.js index b743735a..f9c0b9eb 100644 --- a/src/main/menu/actions/file.js +++ b/src/main/menu/actions/file.js @@ -9,7 +9,7 @@ import { showTabBar } from './view' import { COMMANDS } from '../../commands' import { EXTENSION_HASN, PANDOC_EXTENSIONS, URL_REG } from '../../config' import { normalizeAndResolvePath, writeFile } from '../../filesystem' -import { writeMarkdownFile } from '../../filesystem/markdown' +import { writeMarkdownFile, writeMarkdownFileToIpfs } from '../../filesystem/markdown' import { getPath, getRecommendTitleFromMarkdownString } from '../../utils' import pandoc from '../../utils/pandoc' @@ -158,6 +158,59 @@ const handleResponseForSave = async (e, { id, filename, markdown, pathname, opti }) } +const handleResponseForSaveToIpfs = async (e, { id, filename, markdown, pathname, options, defaultPath }) => { + const win = BrowserWindow.fromWebContents(e.sender) + let recommendFilename = getRecommendTitleFromMarkdownString(markdown) + if (!recommendFilename) { + recommendFilename = filename || 'Untitled' + } + + // If the file doesn't exist on disk add it to the recently used documents later + // and execute file from filesystem watcher for a short time. The file may exists + // on disk nevertheless but is already tracked by MarkText. + const alreadyExistOnDisk = !!pathname + + let filePath = pathname + + if (!filePath) { + const { filePath: dialogPath, canceled } = await dialog.showSaveDialog(win, { + defaultPath: path.join(defaultPath || getPath('documents'), `${recommendFilename}.md`) + }) + + if (dialogPath && !canceled) { + filePath = dialogPath + } + } + + // Save dialog canceled by user - no error. + if (!filePath) { + return Promise.resolve() + } + + filePath = path.resolve(filePath) + const extension = path.extname(filePath) || '.md' + filePath = !filePath.endsWith(extension) ? filePath += extension : filePath + return writeMarkdownFileToIpfs(filePath, markdown, options, win) + .then(() => { + if (!alreadyExistOnDisk) { + ipcMain.emit('window-add-file-path', win.id, filePath) + ipcMain.emit('menu-add-recently-used', filePath) + + const filename = path.basename(filePath) + win.webContents.send('mt::set-pathname', { id, pathname: filePath, filename }) + } else { + ipcMain.emit('window-file-saved', win.id, filePath) + win.webContents.send('mt::tab-saved', id) + } + ipcMain.emit('add-file-to-ipfs', filePath) + return id + }) + .catch(err => { + log.error('Error while saving:', err) + win.webContents.send('mt::tab-save-failure', id, err.message) + }) +} + const showUnsavedFilesMessage = async (win, files) => { const { response } = await dialog.showMessageBox(win, { type: 'warning', @@ -317,6 +370,8 @@ ipcMain.on('mt::close-window-confirm', async (e, unsavedFiles) => { ipcMain.on('mt::response-file-save', handleResponseForSave) +ipcMain.on('mt::response-file-save-to-ipfs', handleResponseForSaveToIpfs) + ipcMain.on('mt::response-export', handleResponseForExport) ipcMain.on('mt::response-print', handleResponseForPrint) @@ -574,6 +629,12 @@ export const save = win => { } } +export const saveToIpfs = win => { + if (win && win.webContents) { + win.webContents.send('mt::editor-ask-file-save-to-ipfs') + } +} + export const saveAs = win => { if (win && win.webContents) { win.webContents.send('mt::editor-ask-file-save-as') diff --git a/src/main/menu/templates/file.js b/src/main/menu/templates/file.js index b62ac0c3..0e7de3b6 100755 --- a/src/main/menu/templates/file.js +++ b/src/main/menu/templates/file.js @@ -33,6 +33,12 @@ export default function (keybindings, userPreference, recentlyUsedFiles) { click (menuItem, browserWindow) { actions.openFolder(browserWindow) } + }, { + label: 'Open File From Ipfs', + accelerator: keybindings.getAccelerator('file.open-file'), + click (menuItem, browserWindow) { + actions.openFile(browserWindow) + } }] } @@ -87,6 +93,12 @@ export default function (keybindings, userPreference, recentlyUsedFiles) { click (menuItem, browserWindow) { actions.saveAs(browserWindow) } + }, { + label: 'Save to Ipfs', + accelerator: keybindings.getAccelerator('file.save'), + click (menuItem, browserWindow) { + actions.saveToIpfs(browserWindow) + } }, { label: 'Auto Save', type: 'checkbox', diff --git a/src/renderer/commands/descriptions.js b/src/renderer/commands/descriptions.js index 4a58e0d3..a7f2f745 100644 --- a/src/renderer/commands/descriptions.js +++ b/src/renderer/commands/descriptions.js @@ -11,6 +11,7 @@ const commandDescriptions = Object.freeze({ 'file.open-folder': 'File: Open Folder', 'file.save': 'File: Save', 'file.save-as': 'File: Save As...', + 'file.save-to-ipfs': 'File: Save to Ipfs...', 'file.move-file': 'File: Move...', 'file.rename-file': 'File: Rename...', 'file.quick-open': 'File: Show quick open dialog', diff --git a/src/renderer/commands/index.js b/src/renderer/commands/index.js index 1c9b15d1..5042c60d 100644 --- a/src/renderer/commands/index.js +++ b/src/renderer/commands/index.js @@ -63,6 +63,11 @@ const commands = [ execute: async () => { ipcRenderer.emit('mt::editor-ask-file-save', null) } + }, { + id: 'file.save-to-ipfs', + execute: async () => { + ipcRenderer.emit('mt::editor-ask-file-save-to-ipfs', null) + } }, { id: 'file.save-as', execute: async () => { diff --git a/src/renderer/pages/app.vue b/src/renderer/pages/app.vue index cd1784a7..946a219e 100644 --- a/src/renderer/pages/app.vue +++ b/src/renderer/pages/app.vue @@ -144,6 +144,7 @@ export default { dispatch('LISTEN_FOR_SAVE_AS') dispatch('LISTEN_FOR_MOVE_TO') dispatch('LISTEN_FOR_SAVE') + dispatch('LISTEN_FOR_SAVE_TO_IPFS') dispatch('LISTEN_FOR_SET_PATHNAME') dispatch('LISTEN_FOR_BOOTSTRAP_WINDOW') dispatch('LISTEN_FOR_SAVE_CLOSE') diff --git a/src/renderer/store/editor.js b/src/renderer/store/editor.js index 3b1c3ee6..05a2fb29 100644 --- a/src/renderer/store/editor.js +++ b/src/renderer/store/editor.js @@ -430,6 +430,24 @@ const actions = { }) }, + LISTEN_FOR_SAVE_TO_IPFS ({ state, rootState }) { + ipcRenderer.on('mt::editor-ask-file-save-to-ipfs', () => { + const { id, filename, pathname, markdown } = state.currentFile + const options = getOptionsFromState(state.currentFile) + const defaultPath = getRootFolderFromState(rootState) + if (id) { + ipcRenderer.send('mt::response-file-save-to-ipfs', { + id, + filename, + pathname, + markdown, + options, + defaultPath + }) + } + }) + }, + // need pass some data to main process when `save as` menu item clicked LISTEN_FOR_SAVE_AS ({ state, rootState }) { ipcRenderer.on('mt::editor-ask-file-save-as', () => {