diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 451bd9d..7bfaf5b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - os: [macOS-latest, ubuntu-18.04, windows-latest] + os: [macOS-latest, ubuntu-18.04] steps: - uses: actions/checkout@v1 @@ -21,10 +21,12 @@ jobs: run: (node -p "require('./package.json').version") > TAG - name: Set action ENV + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' run: echo "::set-env name=TAG::$(cat TAG)" - name: yarn install - run: yarn install + run: yarn install --network-timeout 1000000 - name: Build run: yarn run build @@ -50,12 +52,12 @@ jobs: name: linux path: build/massCode-${{ env.TAG }}.AppImage - - name: Upload Win artifacts - uses: actions/upload-artifact@v1 - if: startsWith(matrix.os, 'windows') - with: - name: win - path: build/massCode Setup ${{ env.TAG }}.exe + # - name: Upload Win artifacts + # uses: actions/upload-artifact@v1 + # if: startsWith(matrix.os, 'windows') + # with: + # name: win + # path: build/massCode Setup ${{ env.TAG }}.exe assets: needs: build @@ -68,6 +70,8 @@ jobs: run: (node -p "require('./package.json').version") > TAG - name: Set action ENV + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' run: echo "::set-env name=TAG::$(cat TAG)" - name: Create Release @@ -96,10 +100,10 @@ jobs: with: name: linux - - name: Dowload Win Artifact - uses: actions/download-artifact@v1 - with: - name: win + # - name: Dowload Win Artifact + # uses: actions/download-artifact@v1 + # with: + # name: win - name: Upload Release Mac Asset (zip) uses: actions/upload-release-asset@v1.0.1 @@ -131,12 +135,12 @@ jobs: asset_name: massCode-${{ env.TAG }}.AppImage asset_content_type: application/vnd.appimage - - name: Upload Release Win Asset - uses: actions/upload-release-asset@v1.0.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: win/massCode Setup ${{ env.TAG }}.exe - asset_name: massCode Setup ${{ env.TAG }}.exe - asset_content_type: application/octet-stream + # - name: Upload Release Win Asset + # uses: actions/upload-release-asset@v1.0.1 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # with: + # upload_url: ${{ steps.create_release.outputs.upload_url }} + # asset_path: win/massCode Setup ${{ env.TAG }}.exe + # asset_name: massCode Setup ${{ env.TAG }}.exe + # asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore index a131fdb..a6a7f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ npm-debug.log.* thumbs.db .env !.gitkeep +.idea diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +node_modules diff --git a/.prettierrc b/.prettierrc index b2095be..9f8ecd9 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,6 @@ { "semi": false, - "singleQuote": true + "singleQuote": true, + "arrowParens": "avoid", + "trailingComma": "none" } diff --git a/package.json b/package.json index 43e7879..36d0134 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "masscode", "productName": "massCode", - "version": "1.3.0", + "version": "2.0.0-beta.1", "author": "Anton Reshetov ", "description": "A free and open source code snippets manager for developers", "license": "AGPL-3.0", @@ -68,11 +68,11 @@ } }, "dependencies": { - "@babel/plugin-proposal-optional-chaining": "^7.10.1", + "@hapi/joi": "^17.1.1", "@johmun/vue-tags-input": "^2.1.0", "axios": "^0.19.1", "date-fns": "^2.8.1", - "electron-store": "^5.1.0", + "electron-store": "^7.0.1", "emmet-monaco-es": "^4.3.3", "feather-icons": "^4.25.0", "fs-extra": "^8.1.0", @@ -80,6 +80,7 @@ "interactjs": "^1.8.0-alpha.6", "junk": "^3.1.0", "lodash-es": "^4.17.15", + "lowdb": "^1.0.0", "markdown-it": "^10.0.0", "markdown-it-link-attributes": "^3.0.0", "monaco-editor": "^0.19.0", @@ -93,14 +94,15 @@ "sanitize-html": "^1.21.1", "shortid": "^2.2.15", "universal-analytics": "^0.4.20", + "uuid": "^8.3.0", "vue": "^2.5.16", - "vue-draggable-nested-tree": "github:massCodeIO/vue-draggable-nested-tree", "vue-electron": "^1.0.6", "vue-router": "^3.1.3", "vuex": "^3.0.1" }, "devDependencies": { "@babel/core": "^7.7.7", + "@babel/plugin-proposal-optional-chaining": "^7.10.1", "@babel/plugin-transform-runtime": "^7.7.6", "@babel/polyfill": "^7.7.0", "@babel/preset-env": "^7.7.7", @@ -117,10 +119,10 @@ "css-loader": "^3.4.0", "del": "^5.1.0", "devtron": "^1.4.0", - "electron": "^7.1.6", + "electron": "^11.2.2", "electron-builder": "^22.4.1", "electron-debug": "^3.0.1", - "electron-devtools-installer": "^2.2.4", + "electron-devtools-installer": "^3.1.1", "eslint": "^6.7.2", "eslint-config-standard": "^14.1.0", "eslint-friendly-formatter": "^4.0.1", diff --git a/src/main/index.dev.js b/src/main/index.dev.js index de6891d..0618dcc 100644 --- a/src/main/index.dev.js +++ b/src/main/index.dev.js @@ -8,7 +8,7 @@ /* eslint-disable */ // Install `electron-debug` with `devtron` -require('electron-debug')({ showDevTools: true }) +require('electron-debug')() // Install `vue-devtools` require('electron').app.on('ready', () => { diff --git a/src/main/lib/datastore/datastore.js b/src/main/lib/datastore/datastore.js new file mode 100644 index 0000000..232ec0a --- /dev/null +++ b/src/main/lib/datastore/datastore.js @@ -0,0 +1,394 @@ +import low from 'lowdb' +import fs from 'fs-extra' +import path from 'path' +import readline from 'readline' +import FileSync from 'lowdb/adapters/FileSync' +import Joi from '@hapi/joi' +import extendMethods from './extend-methods' +import electronStore from '../../store' +import { nestedToFlat } from '../../util/helpers' +import pull from 'lodash-es/pull' +import { format, min, max, isSameDay } from 'date-fns' +import junk from 'junk' +import rimraf from 'rimraf' + +class Datastore { + /** + * @param config.path - path to file (required) + * @param config.backupPath - path to backup folder (required) + * @param config.collections - collections + */ + constructor (config) { + this.initPath(config) + + this.db = low(new FileSync(`${this.path}/db.json`)) + this.collections = {} + this.backupLimit = 30 + this.migrateStore = {} + + this.createCollections(config.collections) + } + + initPath (config) { + if (!config.path) throw Error('config.path is required') + if (!config.backupPath) throw Error('config.backupPath is required') + + fs.ensureDirSync(config.path) + fs.ensureDirSync(config.backupPath) + + this.path = config.path + this.backupPath = config.backupPath + } + + createCollections (collections = []) { + if (collections.length) { + const collectionsObj = {} + + for (const i of collections) { + collectionsObj[i.name] = [] + } + + this.db.defaults(collectionsObj).write() + + for (const { name, schema } of collections) { + this.collections[name] = this.db.get(name) + this.collections[name]._name = name + this.collections[name]._schema = Joi.object(schema) + + for (const m in extendMethods) { + this.collections[name][m] = extendMethods[m] + } + } + } + } + + updateCollections () { + Object.entries(this.collections).map(([p, v]) => { + this.collections[p] = this.db.get(p) + this.collections[p]._name = v._name + this.collections[p]._schema = v._schema + + for (const m in extendMethods) { + this.collections[p][m] = extendMethods[m] + } + }) + } + + clearCollections () { + Object.keys(this.collections).map(i => this.db.set(i, []).write()) + } + + createDefaultFolders () { + if (this.collections.folders.size().value()) return + + const systemFolders = [ + { name: 'Inbox', isSystem: true, parentId: null, alias: 'inbox' }, + { name: 'Favorites', isSystem: true, parentId: null, alias: 'favorites' }, + { + name: 'All Snippets', + isSystem: true, + parentId: null, + alias: 'allSnippets' + }, + { name: 'Trash', isSystem: true, parentId: null, alias: 'trash' } + ] + const defaultFolder = { + name: 'Default', + parentId: null, + defaultLanguage: 'text', + index: -1 + } + const folders = [...systemFolders, defaultFolder] + + folders.map(i => { + this.collections.folders.$insert(i) + }) + } + + import (from) { + electronStore.preferences.set('storagePath', from) + this.updatePath(from) + } + + move (to) { + return new Promise((resolve, reject) => { + const src = path.resolve(`${this.path}/db.json`) + const dist = path.resolve(to, 'db.json') + + console.warn(src, dist) + + fs.readdir(to, (err, files) => { + console.log(files) + if (err) reject(err) + + if (files.includes('db.json')) { + reject(new Error('Folder already contains DB.')) + } + fs.moveSync(src, dist) + electronStore.preferences.set('storagePath', to) + this.updatePath(to) + resolve() + }) + }) + } + + async updatePath (path) { + this.path = path + this.db = low(new FileSync(`${path}/db.json`)) + this.updateCollections() + } + + // Backup + + async createBackupDirByDate (date) { + const folderPath = this.convertDateToBackupPath(date) + await fs.ensureDir(folderPath) + + return folderPath + } + + convertDateToBackupPath (date) { + date = date || new Date() + const backupFolderDatePattern = 'yyyy-MM-dd_HH-mm-ss' + const suffixFolder = 'massCode_v2' + const dirName = `${format(date, backupFolderDatePattern)}_${suffixFolder}` + + return path.resolve(this.backupPath, dirName) + } + + async backup () { + const dir = await this.createBackupDirByDate() + const src = path.resolve(`${this.path}/db.json`) + const dest = path.resolve(dir, 'db.json') + + await fs.copy(src, dest) + } + + autoBackup () { + const start = async () => { + const now = new Date() + const isEmpty = await this.isBackupEmpty() + + if (isEmpty) { + await this.backup() + } else { + const { date } = await this.getLatestBackupDir() + + if (!isSameDay(now, date)) { + await this.removeEarliestBackup() + await this.backup() + } + } + console.log('autobackup is started') + } + + start() + setInterval(() => { + start() + }, 1000 * 60 * 60 * 12) + } + + restoreFromBackup (date) { + return new Promise((resolve, reject) => { + const dir = this.convertDateToBackupPath(date) + fs.copyFileSync(`${dir}/db.json`, `${this.path}/db.json`) + this.updatePath(this.path) + resolve() + }) + } + + async moveBackup (to) { + const dirs = await this.getBackupDirs() + const src = dirs.map(i => path.resolve(this.backupPath, i)) + const dest = dirs.map(i => path.resolve(to, i)) + + src.forEach((dir, index) => { + fs.moveSync(dir, dest[index], { overwrite: true }) + }) + + this.backupPath = to + electronStore.preferences.set('backupPath', to) + } + + async getBackupDirs () { + let dirs = await fs.readdir(this.backupPath) + dirs = dirs.filter(junk.not).filter(i => i.includes('massCode_v2')) + + return dirs + } + + async getBackupsDirsAsDate () { + const dirs = await this.getBackupDirs() + return this.convertBackupDirsToDate(dirs.filter(junk.not)) + } + + async getEarliestBackupDir () { + const dirs = await this.getBackupDirs() + + const dirsDate = this.convertBackupDirsToDate(dirs) + const minDate = min(dirsDate).getTime() + const dir = dirs[dirsDate.indexOf(minDate)] + + return { + date: minDate, + dir, + path: dir ? path.resolve(this.backupPath, dir) : null + } + } + + async getLatestBackupDir () { + const dirs = await this.getBackupDirs() + + const dirsDate = this.convertBackupDirsToDate(dirs) + const maxDate = max(dirsDate).getTime() + const dir = dirs[dirsDate.indexOf(maxDate)] + + return { + date: maxDate, + dir, + path: dir ? path.resolve(this.backupPath, dir) : null + } + } + + async removeEarliestBackup () { + const dirs = await this.getBackupDirs() + const { path } = await this.getEarliestBackupDir() + + if (dirs.length > this.backupLimit) { + rimraf(path, err => { + if (err) throw Error(err) + }) + } + } + + convertBackupDirsToDate (dirs) { + return dirs.map(i => { + const arr = i.split('_').splice(0, 2) + arr[1] = `T${arr[1].replace(/-/g, ':')}` + const date = new Date(arr.join('')).getTime() + + return date + }) + } + + async isBackupEmpty () { + let dirs = await this.getBackupDirs() + dirs = dirs.filter(junk.not) + + return dirs.length === 0 + } + + // Migrate + + async migrate (path) { + if (!path) throw Error('"path" is required') + + const files = await fs.readdir(path) + const migrateFiles = ['masscode.db', 'snippets.db', 'tags.db'] + + const isFilesExist = migrateFiles + .reduce((acc, item) => { + acc.push(files.includes(item)) + return acc + }, []) + .every(i => i === true) + + if (!isFilesExist) throw Error('DB files not exist in this folder') + + console.log('Migrate from v1 is started') + this.clearCollections() + this.createDefaultFolders() + + const convertDBToJSON = async file => { + const readInterface = readline.createInterface({ + input: fs.createReadStream(`${path}/${file}.db`), + output: process.stdout, + console: false + }) + const arr = [] + + return new Promise((resolve, reject) => { + readInterface.on('line', line => { + if (line) arr.push(JSON.parse(line)) + }) + + readInterface.on('close', () => { + resolve(arr) + }) + }) + } + + const masscodeJSON = await convertDBToJSON('masscode') + const snippetsJSON = await convertDBToJSON('snippets') + const tagsJSON = await convertDBToJSON('tags') + const masscodeJSONList = nestedToFlat(masscodeJSON[0].list) + + this.migrateStore.folderIdsMap = [] + this.migrateStore.tagIdsMap = [] + + return new Promise((resolve, reject) => { + // Folders + masscodeJSONList.map(({ id, ...rest }) => { + const { _id } = this.collections.folders.$insert(rest) + + this.migrateStore.folderIdsMap.push([id, _id]) + this.migrateStore.folderIdsMap.map(([oldId, newId]) => { + this.collections.folders + .find({ parentId: oldId }) + .assign({ parentId: newId }) + .write() + }) + }) + // Snippets + snippetsJSON.map( + ({ + _id, + tags, + folderId, + createdAt, + updatedAt, + folder, + tagsPopulated, + ...rest + }) => { + const [, newId] = + this.migrateStore.folderIdsMap.find(item => + item.includes(folderId) + ) || [] + + this.collections.snippets.$insert({ + ...rest, + tagIds: tags, + folderId: newId || null + }) + } + ) + // Tags + tagsJSON.map(({ _id: oldId, ...rest }) => { + const { _id } = this.collections.tags.$insert(rest) + this.migrateStore.tagIdsMap.push([oldId, _id]) + }) + + this.migrateStore.tagIdsMap.map(([oldId, newId]) => { + const snippetsWithTags = this.collections.snippets + .filter(i => i.tagIds.includes(oldId)) + .cloneDeep() + .value() + + snippetsWithTags.map(s => { + const { _id, tagIds } = s + pull(tagIds, oldId) + tagIds.push(newId) + this.collections.snippets + .find({ _id }) + .assign({ tagIds }) + .write() + }) + }) + + resolve(console.log('Migrate from v1 is completed')) + }) + } +} + +export default Datastore diff --git a/src/main/lib/datastore/extend-methods.js b/src/main/lib/datastore/extend-methods.js new file mode 100644 index 0000000..e6ccf51 --- /dev/null +++ b/src/main/lib/datastore/extend-methods.js @@ -0,0 +1,193 @@ +import { v4 as uuid } from 'uuid' +import cloneDeep from 'lodash-es/cloneDeep' +import merge from 'lodash-es/merge' +import { deleteTechProps } from './helpers' + +const extendMethods = { + /** + * Добавление документа в коллекцию + * @param doc {Object} - Документ + */ + $insert (doc, customId = false) { + const { error, value } = this._schema.validate(doc) + + if (error) { + throw new Error(error) + } else { + doc = value + doc._id = uuid() + doc.createdAt = new Date().getTime() + doc.updatedAt = new Date().getTime() + this.push(doc).write() + return this.$findOne({ _id: doc._id }) + } + }, + /** + * Поиск по фильтру с возвратом списка документов + * @param filter {Object} - Фильтр поиска + * @returns {Array} - Список найденных документов + */ + $find (filter = {}) { + return this.filter(filter) + .cloneDeep() + .value() + }, + /** + * Поиск по фильтру с возвратом документа + * @param filter {Object} - Фильтр поиска + * @returns {Object} - Найденный документ + */ + $findOne (filter = {}) { + return this.find(filter) + .cloneDeep() + .value() + }, + /** + * Обновление документа по ID + * @param filter {Object} - Фильтр поиска + * @param update - {Object} - Обновления для документа + * @returns {Object} - Патч обновление + */ + $findOneAndUpdate (filter, update) { + const doc = this.find(filter) + const docData = doc.cloneDeep().value() + const updatedDoc = merge(deleteTechProps(docData), deleteTechProps(update)) + + const { error } = this._schema.validate(updatedDoc) + + if (error) { + throw new Error(error) + } else { + doc + .assign(updatedDoc) + .set('updatedAt', new Date().getTime()) + .write() + + return doc + } + }, + /** + * Агрегация значений + * @param pipeline[].$match {Object} - Фильтр поиска + * @param pipeline[].$lookup {Object} - Присоединение коллекции + */ + $aggregate (pipeline = []) { + let docs = [] + console.group('$aggregate') + pipeline.forEach(pipe => { + for (const [k, v] of Object.entries(pipe)) { + console.log(k, v) + // $match принимает следующие значения: + // Простые + // - key: value + // Комплексные + // - key: {$in: []} + // - key: {$elemMatch: []} + if (k === '$match') { + const simpleKV = {} + for (const [kM, vM] of Object.entries(v)) { + console.log(kM, vM) + if (typeof vM !== 'object') simpleKV[kM] = vM + // console.log('simpleKV', simpleKV) + if (vM.$in) { + // Найти все сниппеты у которых в поле kM (например folderId) + // присутствует одно из vM.$in значений + console.group('$in') + const docsVm = vM.$in.reduce((acc, item) => { + // console.log({ [kM]: item }) + const query = { + ...simpleKV, + [kM]: item + } + console.log('query', query) + const doc = this.filter(query) + .cloneDeep() + .value() + acc.push(...doc) + return acc + }, []) + console.log(docsVm) + console.groupEnd() + docs = [...docs, ...docsVm] + } else if (vM.$elemMatch) { + // Найти все сниппеты у которых в поле-массиве kM (например tagIds) + // присутствует одно из vM.$elemMatch значений + const docsVm = this.filter({}) + .cloneDeep() + .value() + .reduce((acc, item) => { + if (item.tagIds.includes(vM.$elemMatch)) { + acc.push(item) + } + return acc + }, []) + docs = [...docs, ...docsVm] + console.log(docsVm) + console.group('$elemMatch') + console.warn(kM, vM) + console.groupEnd() + } else { + docs = this.filter(v) + .cloneDeep() + .value() + } + } + + if (!Object.keys(v).length) { + console.log('query', v) + docs = this.filter(v) + .cloneDeep() + .value() + } + } + + if (k === '$lookup') { + if (docs) { + // Присоединенная коллекция + const collection = this.__wrapped__[v.from] + + docs.map(doc => { + let populate + + if (Array.isArray(doc[v.localField])) { + populate = doc[v.localField].map(id => { + return collection.find(i => i[v.foreignField] === id) + }) + } else { + populate = collection.find( + i => i[v.foreignField] === doc[v.localField] + ) + } + + if (populate) doc[v.as] = cloneDeep(populate) + + return doc + }) + } + } + + if (k === '$sort') { + for (const [sK, sV] of Object.entries(v)) { + if (docs) { + sV === 1 + ? docs.sort((a, b) => (a[sK] > b[sK] ? -1 : 1)) + : docs.sort((a, b) => (a[sK] < b[sK] ? 1 : -1)) + } + } + } + + if (k === '$limit') { + docs = docs.slice(0, v) + } + + if (k === '$skip') { + docs = docs.slice(v) + } + } + }) + console.groupEnd() + return docs + } +} + +export default extendMethods diff --git a/src/main/lib/datastore/helpers.js b/src/main/lib/datastore/helpers.js new file mode 100644 index 0000000..3f4dfd6 --- /dev/null +++ b/src/main/lib/datastore/helpers.js @@ -0,0 +1,19 @@ +import cloneDeep from 'lodash-es/cloneDeep' + +/** + * Удаление технических полей базы данных + * @param obj - объект + * @returns {Object} - объект без технических полей + */ +export function deleteTechProps (obj) { + obj = cloneDeep(obj) + + delete obj._id + delete obj.children + delete obj.tags + delete obj.folder + delete obj.createdAt + delete obj.updatedAt + + return obj +} diff --git a/src/main/lib/datastore/index.js b/src/main/lib/datastore/index.js new file mode 100644 index 0000000..887e4a1 --- /dev/null +++ b/src/main/lib/datastore/index.js @@ -0,0 +1,35 @@ +import DB from './datastore' +import store from '../../store' +import { + FOLDERS_SCHEMA, + SNIPPETS_SCHEMA, + TAGS_SCHEMA +} from '../datastore/schema' + +const path = store.preferences.get('storagePath') +const backupPath = store.preferences.get('backupPath') +const collections = [ + { + name: 'folders', + schema: FOLDERS_SCHEMA + }, + { + name: 'snippets', + schema: SNIPPETS_SCHEMA + }, + { + name: 'tags', + schema: TAGS_SCHEMA + } +] + +const db = new DB({ + path, + backupPath, + collections +}) + +db.createDefaultFolders() +db.autoBackup() + +export default db diff --git a/src/main/lib/datastore/schema.js b/src/main/lib/datastore/schema.js new file mode 100644 index 0000000..8977ea0 --- /dev/null +++ b/src/main/lib/datastore/schema.js @@ -0,0 +1,28 @@ +import Joi from '@hapi/joi' + +export const FOLDERS_SCHEMA = { + name: Joi.string().required(), + defaultLanguage: Joi.string(), + parentId: Joi.string() + .allow(null) + .required(), + index: Joi.number().allow(null), + open: Joi.boolean().default(false), + isSystem: Joi.boolean().default(false), + alias: Joi.string() +} + +export const SNIPPETS_SCHEMA = { + name: Joi.string().required(), + content: Joi.array().default([]), + folderId: Joi.string() + .default(null) + .allow(null), + tagIds: Joi.array().default([]), + isFavorites: Joi.boolean().default(false), + isDeleted: Joi.boolean().default(false) +} + +export const TAGS_SCHEMA = { + name: Joi.string().required() +} diff --git a/src/main/lib/update-check.js b/src/main/lib/update-check.js index 12dc26a..aac838c 100644 --- a/src/main/lib/update-check.js +++ b/src/main/lib/update-check.js @@ -9,6 +9,10 @@ function checkForUpdatesAndNotify () { async function check () { const currentVersion = pkg.version + const reBeta = /beta/ + + if (reBeta.test(currentVersion)) return + const res = await axios.get( 'https://github.com/antonreshetov/masscode/releases/latest' ) @@ -25,6 +29,7 @@ function checkForUpdatesAndNotify () { } check() + // setInterval(check, 1000 * 60 * 15) setInterval(check, 1000 * 60 * 15) } diff --git a/src/main/main.js b/src/main/main.js index 994e48a..40d6414 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -27,7 +27,8 @@ function createMainWindow () { transparent: process.platform === 'darwin', backgroundColor, webPreferences: { - nodeIntegration: true + nodeIntegration: true, + enableRemoteModule: true } }) @@ -35,7 +36,13 @@ function createMainWindow () { mainWindow.loadURL(winURL) if (isDev) { - mainWindow.webContents.openDevTools({ mode: 'detach' }) + // @see https://github.com/SimulatedGREG/electron-vue/issues/389 + mainWindow.webContents.on('did-frame-finish-load', () => { + mainWindow.webContents.once('devtools-opened', () => { + mainWindow.focus() + }) + mainWindow.webContents.openDevTools({ mode: 'bottom' }) + }) } if (process.platform === 'darwin') { diff --git a/src/main/store/module/app.js b/src/main/store/module/app.js index 363c72a..151bf4b 100644 --- a/src/main/store/module/app.js +++ b/src/main/store/module/app.js @@ -15,7 +15,7 @@ const app = new Store({ default: 220 }, selectedFolderId: { - default: 'inBox' + default: 'inbox' }, selectedFolderIds: { default: null diff --git a/src/main/tray.js b/src/main/tray.js index dfeee53..ac10d0e 100644 --- a/src/main/tray.js +++ b/src/main/tray.js @@ -46,7 +46,8 @@ function createTrayWindow () { movable: false, webPreferences: { nodeIntegration: true, - devTools: false + devTools: false, + enableRemoteModule: true } }) diff --git a/src/main/util/helpers.js b/src/main/util/helpers.js new file mode 100644 index 0000000..6ad7983 --- /dev/null +++ b/src/main/util/helpers.js @@ -0,0 +1,69 @@ +/** + * Конвертация вложенного строения дерева с 'children' в плоское + * @param {Array} items - массив элементов + * @param {String} link - связь по полю + * @returns {Array} - плоский массив со связями по полю 'parentId' + */ +export function nestedToFlat (items, link = 'id') { + const flatList = [] + + function flat (items) { + items.map((i, index) => { + if (i.children && i.children.length) { + const children = i.children.map((item, idx) => { + return { + ...item, + index: idx, + parentId: i[link] + } + }) + + flatList.push(...children) + + if (!flatList.find(l => l[link] === i[link])) { + flatList.push({ + ...i, + index, + parentId: null + }) + } + + flat(i.children) + } else { + if (!flatList.find(l => l[link] === i[link])) { + flatList.push({ + ...i, + index, + parentId: null + }) + } + } + }) + } + + flat(items) + + return flatList.map(({ children, ...rest }) => rest) +} +/** + * Конвертация плоского строения дерева во вложенный через 'children' + * @param {Array} items - массив элементов c полями связей + * с родительскими элементами + * @param {String} id - ID элемента + * @param {String} idLink - имя свойства ID + * @param {String} link - имя связанного поля + * @example [{id:1, parentId: null }, {id:2, parentId: 1 }] -> [{id:1, children: [id:2] }] + */ +export function flatToNested ( + items, + id = null, + idLink = '_id', + link = 'parentId' +) { + return items + .filter(item => item[link] === id) + .map((item, index) => ({ + ...item, + children: flatToNested(items, item[idLink]) + })) +} diff --git a/src/renderer/App.vue b/src/renderer/App.vue index d8b4539..4783103 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -29,7 +29,8 @@ export default { ...mapGetters('folders', [ 'selectedIds', 'defaultQueryBySystemFolder', - 'isSystemFolder' + 'isSystemFolder', + 'systemAliases' ]), ...mapGetters('snippets', ['snippetsBySort']), isTray () { @@ -64,31 +65,23 @@ export default { const selectedSnippetId = electronStore.app.get('selectedSnippetId') if (selectedFolderId) { - this.$store.dispatch('folders/setSelectedFolder', selectedFolderId) - - let query = {} - - if (this.isSystemFolder) { - query = this.defaultQueryBySystemFolder - } - - if (this.selectedIds) { - query = { folderId: { $in: this.selectedIds } } - } + const isFolderExist = await this.$store.dispatch( + 'folders/isFolderExist', + selectedFolderId + ) + const folderId = isFolderExist + ? selectedFolderId + : this.systemAliases.inbox - await this.$store.dispatch('snippets/getSnippets', query) + await this.$store.dispatch('folders/setSelectedFolderById', folderId) + await this.$store.dispatch('snippets/getSnippetsBySelectedFolders') } else { - await this.$store.dispatch('snippets/getSnippets', { folderId: null }) + await this.$store.dispatch('snippets/getSnippets') } if (selectedSnippetId) { - const snippet = this.snippetsBySort.find( - i => i._id === selectedSnippetId - ) - if (snippet) { - this.$store.dispatch('snippets/setSelected', snippet) - this.$store.commit('snippets/SET_SELECTED_SNIPPETS', [snippet]) - } + await this.$store.dispatch('snippets/setSelected', selectedSnippetId) + this.$store.commit('snippets/SET_SELECTED_IDS', [selectedSnippetId]) } this.$store.commit('app/SET_INIT', true) diff --git a/src/renderer/components/editor/MonacoEditor.vue b/src/renderer/components/editor/MonacoEditor.vue index 095117a..063d9e6 100644 --- a/src/renderer/components/editor/MonacoEditor.vue +++ b/src/renderer/components/editor/MonacoEditor.vue @@ -316,7 +316,9 @@ export default { }, searchHighlight (query) { const model = this.editor.getModel() - const matches = model.findMatches(query, false, true, false) + const re = new RegExp(query ? query.replace(' ', '|') : null) + const matches = model.findMatches(re, false, true, false) + const newDecorations = matches.map(i => { return { range: i.range, diff --git a/src/renderer/components/preferences/Storage.vue b/src/renderer/components/preferences/Storage.vue index 04661d8..f3036b5 100644 --- a/src/renderer/components/preferences/Storage.vue +++ b/src/renderer/components/preferences/Storage.vue @@ -9,10 +9,10 @@ size="small" class="preferences__input" /> - + Move storage - + Open storage @@ -50,7 +50,6 @@ :key="index" class="backups__item" > - {{ i.label }} + +
+ + Open folder + +
+
+ You can migrate from massCode v1, select the folder with old DB files. +
+
{{ countText }} @@ -69,8 +78,8 @@ import { mapState, mapGetters } from 'vuex' import { dialog } from '@@/lib' import { ipcRenderer } from 'electron' -import db from '@/datastore' import PerfectScrollbar from 'perfect-scrollbar' +import db from '@@/lib/datastore' export default { name: 'Storage', @@ -85,6 +94,7 @@ export default { ...mapState(['app']), ...mapGetters('app', ['backups']), ...mapGetters('snippets', ['count']), + ...mapGetters('folders', ['systemAliases']), storagePath: { get () { return this.app.storagePath @@ -117,7 +127,7 @@ export default { }, methods: { - async onChangeStorage () { + async onMoveStorage () { const dir = dialog.showOpenDialogSync({ properties: ['openDirectory', 'createDirectory'] }) @@ -125,39 +135,34 @@ export default { const path = dir[0] try { await db.move(path) - this.updateData() this.$store.commit('app/SET_STORAGE_PATH', path) } catch (err) { ipcRenderer.send('message', { message: 'Error', type: 'error', - detail: - 'Folder already contains db files. Please select another folder.' + detail: 'Folder already contains DB. Please select another folder.' }) } } }, - async onOpenStorage () { + onOpenStorageFolder () { const dir = dialog.showOpenDialogSync({ properties: ['openDirectory'] }) if (dir) { const path = dir[0] db.import(path) - this.updateData() this.$store.commit('app/SET_STORAGE_PATH', path) + this.$store.dispatch('folders/getFolders') + this.$store.dispatch('snippets/getSnippets') + this.$store.dispatch('snippets/getSnippetsCount') + this.$store.dispatch('tags/getTags') } }, - async updateData () { - this.$store.dispatch('snippets/setSelected', null) - this.$store.dispatch('folders/setSelectedFolder', 'allSnippets') - await this.$store.dispatch('folders/getFolders') - await this.$store.dispatch('snippets/getSnippets') - }, async onBackup () { await db.removeEarliestBackup() await db.backup() - this.$store.dispatch('app/getBackups') + await this.$store.dispatch('app/getBackups') this.$nextTick(() => { this.ps.update() }) @@ -195,6 +200,50 @@ export default { } } }, + async onOpenMigrateFolder () { + const dir = dialog.showOpenDialogSync({ + properties: ['openDirectory'] + }) + const path = dir ? dir[0] : null + + if (!path) return + + const buttonId = dialog.showMessageBoxSync({ + message: 'Are you sure you want to migrate from v1?', + detail: + 'During migrate from old DB, the current library will be overwritten.', + buttons: ['Confirm', 'Cancel'], + defaultId: 0, + cancelId: 1 + }) + + if (buttonId === 1) return + + try { + await db.migrate(path) + this.$store.dispatch('folders/getFolders') + this.$store.dispatch('snippets/getSnippets') + this.$store.dispatch('snippets/getSnippetsCount') + this.$store.dispatch('tags/getTags') + } catch (err) { + dialog.showMessageBoxSync({ + title: 'Error', + message: 'DB files not exist in this folder', + type: 'error' + }) + } + }, + async updateData () { + this.$store.dispatch('snippets/setSelected', null) + this.$store.dispatch( + 'folders/setSelectedFolderById', + this.systemAliases.allSnippets + ) + this.$store.dispatch('folders/getFolders') + this.$store.dispatch('snippets/getSnippets') + this.$store.dispatch('snippets/getSnippetsCount') + this.$store.dispatch('tags/getTags') + }, getData () { this.$store.dispatch('app/getBackups') this.$store.dispatch('snippets/getSnippetsCount') diff --git a/src/renderer/components/sidebar/FolderItem.vue b/src/renderer/components/sidebar/FolderItem.vue new file mode 100644 index 0000000..9376c63 --- /dev/null +++ b/src/renderer/components/sidebar/FolderItem.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/src/renderer/components/sidebar/SidebarListItem.vue b/src/renderer/components/sidebar/SidebarListItem.vue index e77ea64..d366da2 100644 --- a/src/renderer/components/sidebar/SidebarListItem.vue +++ b/src/renderer/components/sidebar/SidebarListItem.vue @@ -94,8 +94,18 @@ export default { }, computed: { - ...mapGetters('folders', ['selectedId', 'editableId']), + ...mapGetters('folders', [ + 'selected', + 'selectedId', + 'selectedIds', + 'editableId', + 'system', + 'isSystemFolder', + 'defaultQueryBySystemFolder', + 'systemAliases' + ]), ...mapGetters('tags', { selectedTagId: 'selectedId' }), + ...mapGetters('snippets', ['snippetsBySort']), languagesMenu () { return languages .map(i => { @@ -103,8 +113,8 @@ export default { i.checked = i.value === this.model.defaultLanguage i.click = e => { const id = this.id - const payload = e.value - this.$store.dispatch('folders/updateFolderLanguage', { + const payload = { defaultLanguage: e.value } + this.$store.dispatch('folders/updateFolderById', { id, payload }) @@ -134,10 +144,6 @@ export default { }, isEditable () { return this.editableId === this.id - }, - isSystem () { - const libraryItems = ['inBox', 'favorites', 'allSnippets', 'trash'] - return libraryItems.includes(this.id) } }, @@ -148,23 +154,23 @@ export default { }, methods: { - onClick () { + async onClick () { if (!this.tag) { - this.$store.dispatch('folders/setSelectedFolder', this.id) + await this.getSnippetsByFolder() } else { - this.$store.commit('tags/SET_SELECTED_ID', this.id) + await this.getSnippetsByTag(this.id) } }, onDblClick () { - if (!this.isSystem) this.setEditable() + if (!this.isSystemFolder) this.setEditable() }, onContext () { if (!this.tag) { - if (this.id === 'trash') { + if (this.id === this.systemAliases.trash) { return this.trashContext() } - if (this.isSystem) return + if (this.isSystemFolder) return this.folderContext() } else { @@ -190,7 +196,10 @@ export default { cancelId: 1 }) if (buttonId === 0) { - this.$store.dispatch('folders/deleteFolder', this.id) + this.$store.dispatch( + 'folders/deleteFolderByIds', + this.selectedIds + ) track('folders/delete') } } @@ -270,8 +279,53 @@ export default { } if (this.updatedFolderName) { const id = this.id - const payload = this.updatedFolderName - this.$store.dispatch('folders/updateFolderName', { id, payload }) + const payload = { + name: this.updatedFolderName + } + this.$store.dispatch('folders/updateFolderNameById', { + id, + payload + }) + } + }, + async getSnippetsByFolder () { + await this.$store.dispatch('folders/setSelectedFolderById', this.id) + + let query = { folderId: { $in: this.selectedIds } } + + if (this.isSystemFolder) { + query = this.defaultQueryBySystemFolder + } + + await this.$store.dispatch('snippets/getSnippets', query) + const firstSnippet = this.snippetsBySort[0] + + if (firstSnippet) { + await this.$store.dispatch('snippets/setSelected', firstSnippet._id) + this.$store.commit('snippets/SET_SELECTED_IDS', [firstSnippet._id]) + } else { + await this.$store.dispatch('snippets/setSelected', null) + this.$store.commit('snippets/SET_SELECTED_IDS', []) + } + }, + async getSnippetsByTag (id) { + this.$store.commit('tags/SET_SELECTED_ID', this.id) + await this.$store.dispatch('snippets/getSnippets', { + tagIds: { $elemMatch: id } + }) + const firstSnippet = this.snippetsBySort[0] + if (firstSnippet) { + this.$store.commit('snippets/SET_SELECTED_ID', firstSnippet._id) + this.$store.commit('snippets/SET_ACTIVE_FRAGMENT', { + snippetId: firstSnippet._id, + index: 0 + }) + } else { + this.$store.commit('snippets/SET_SELECTED_ID', null) + this.$store.commit('snippets/SET_ACTIVE_FRAGMENT', { + snippetId: null, + index: 0 + }) } } } @@ -287,25 +341,30 @@ export default { cursor: default; user-select: none; position: relative; + &__input { width: 100%; border: 1px solid transparent; background-color: transparent; outline: none; + &[disabled] { color: var(--color-text); } + &.is-editable { border: 1px solid var(--color-primary); background-color: var(--color-contrast-lower); color: var(--color-contrast-higher); } } + .folder-name { display: flex; height: 20px; align-items: center; } + svg { width: 16px; height: 16px; @@ -314,25 +373,32 @@ export default { margin-right: var(--spacing-xs); stroke: var(--color-contrast-medium); } + &:last-child { margin-bottom: 0; } + &--selected { background-color: var(--color-contrast-low); } + &--drag-hovered { background-color: var(--color-primary); color: #fff; position: relative; + #{$r}__input { color: #fff; } + svg { stroke: #fff; } } + &--context { position: relative; + &::after { content: ''; position: absolute; @@ -344,15 +410,18 @@ export default { border-radius: 4px; } } + &__child-icon { position: absolute; top: 6px; left: 2px; + &.is-open { svg { transform: rotate(90deg); } } + svg { transition: all 0.1s; width: 14px; diff --git a/src/renderer/components/sidebar/TheFolders.vue b/src/renderer/components/sidebar/TheFolders.vue index 2bfcb71..845a1a1 100644 --- a/src/renderer/components/sidebar/TheFolders.vue +++ b/src/renderer/components/sidebar/TheFolders.vue @@ -1,34 +1,24 @@