diff --git a/libraries/codemirror/addon/lint/eslint.js b/libraries/codemirror/addon/lint/eslint.js index 5c310fa63..b1ab412e3 100644 --- a/libraries/codemirror/addon/lint/eslint.js +++ b/libraries/codemirror/addon/lint/eslint.js @@ -46,7 +46,7 @@ const errors = new eslint().verify(text, { root: true, parserOptions: { - ecmaVersion: 2019 + ecmaVersion: 2022 }, extends: ['eslint:recommended', 'airbnb-base'], env: { diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 000000000..df14c4a84 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,13 @@ +{ + "restartable": "rs", + "ignore": [".git", "node_modules/**/node_modules", "src/public/"], + "verbose": false, + "execMap": { + "js": "node --harmony" + }, + "watch": ["src/"], + "env": { + "NODE_ENV": "development" + }, + "ext": "js,json" +} diff --git a/package.json b/package.json index 8728f961a..cce7feb50 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,16 @@ "url": "https://github.com/zadam/trilium.git" }, "scripts": { - "start-server": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev TRILIUM_SYNC_SERVER_HOST=http://tsyncserver:4000 node ./src/www", - "start-server-no-dir": "cross-env TRILIUM_ENV=dev TRILIUM_SYNC_SERVER_HOST=http://tsyncserver:4000 node ./src/www", + "start-server": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev TRILIUM_SYNC_SERVER_HOST=http://tsyncserver:4000 nodemon ./src/www", + "start-server-no-dir": "cross-env TRILIUM_ENV=dev TRILIUM_SYNC_SERVER_HOST=http://tsyncserver:4000 nodemon ./src/www", "start-electron": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_SYNC_SERVER_HOST=http://tsyncserver:4000 TRILIUM_ENV=dev electron --inspect=5858 .", "start-electron-no-dir": "cross-env TRILIUM_ENV=dev TRILIUM_SYNC_SERVER_HOST=http://tsyncserver:4000 electron --inspect=5858 .", "switch-server": "rm -rf ./node_modules/better-sqlite3 && npm install", - "switch-electron": "rm -rf ./node_modules/better-sqlite3 && npm install && ./node_modules/.bin/electron-rebuild", + "switch-electron": "./node_modules/.bin/electron-rebuild", "build-backend-docs": "rm -rf ./docs/backend_api && ./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/backend_api src/becca/entities/*.js src/services/backend_script_api.js src/services/sql.js", "build-frontend-docs": "rm -rf ./docs/frontend_api && ./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/frontend_api src/public/app/entities/*.js src/public/app/services/frontend_script_api.js src/public/app/widgets/right_panel_widget.js", "build-docs": "npm run build-backend-docs && npm run build-frontend-docs", - "webpack": "npx webpack -c webpack-desktop.config.js && npx webpack -c webpack-mobile.config.js && npx webpack -c webpack-setup.config.js", + "webpack": "webpack -c webpack.config.js", "test-jasmine": "jasmine", "test-es6": "node -r esm spec-es6/attribute_parser.spec.js ", "test": "npm run test-jasmine && npm run test-es6", @@ -115,6 +115,7 @@ "jsdoc": "4.0.2", "lorem-ipsum": "2.0.8", "prettier": "2.8.7", + "nodemon": "^2.0.22", "rcedit": "3.0.1", "webpack": "5.78.0", "webpack-cli": "5.0.1" diff --git a/src/becca/becca_service.js b/src/becca/becca_service.js index 80942c564..aad6fff5b 100644 --- a/src/becca/becca_service.js +++ b/src/becca/becca_service.js @@ -24,49 +24,12 @@ function isNotePathArchived(notePath) { return false; } -/** - * This assumes that note is available. "archived" note means that there isn't a single non-archived note-path - * leading to this note. - * - * @param noteId - */ -function isArchived(noteId) { - const notePath = getSomePath(noteId); - - return isNotePathArchived(notePath); -} - -/** - * @param {string} noteId - * @param {string} ancestorNoteId - * @returns {boolean} - true if given noteId has ancestorNoteId in any of its paths (even archived) - */ -function isInAncestor(noteId, ancestorNoteId) { - if (ancestorNoteId === 'root' || ancestorNoteId === noteId) { - return true; - } - - const note = becca.notes[noteId]; - - if (!note) { - return false; - } - - for (const parentNote of note.parents) { - if (isInAncestor(parentNote.noteId, ancestorNoteId)) { - return true; - } - } - - return false; -} - function getNoteTitle(childNoteId, parentNoteId) { const childNote = becca.notes[childNoteId]; const parentNote = becca.notes[parentNoteId]; if (!childNote) { - log.info(`Cannot find note in cache for noteId '${childNoteId}'`); + log.info(`Cannot find note '${childNoteId}'`); return "[error fetching title]"; } @@ -119,107 +82,8 @@ function getNoteTitleForPath(notePathArray) { return titles.join(' / '); } -/** - * Returns notePath for noteId from cache. Note hoisting is respected. - * Archived (and hidden) notes are also returned, but non-archived paths are preferred if available - * - this means that archived paths is returned only if there's no non-archived path - * - you can check whether returned path is archived using isArchived - * - * @param {BNote} note - * @param {string[]} path - */ -function getSomePath(note, path = []) { - // first try to find note within hoisted note, otherwise take any existing note path - return getSomePathInner(note, path, true) - || getSomePathInner(note, path, false); -} - -/** - * @param {BNote} note - * @param {string[]} path - * @param {boolean}respectHoisting - * @returns {string[]|false} - */ -function getSomePathInner(note, path, respectHoisting) { - if (note.isRoot()) { - const foundPath = [...path, note.noteId]; - foundPath.reverse(); - - if (respectHoisting && !foundPath.includes(cls.getHoistedNoteId())) { - return false; - } - - return foundPath; - } - - const parents = note.parents; - if (parents.length === 0) { - console.log(`Note '${note.noteId}' - '${note.title}' has no parents.`); - - return false; - } - - for (const parentNote of parents) { - const retPath = getSomePathInner(parentNote, [...path, note.noteId], respectHoisting); - - if (retPath) { - return retPath; - } - } - - return false; -} - -function getNotePath(noteId) { - const note = becca.notes[noteId]; - - if (!note) { - console.trace(`Cannot find note '${noteId}' in cache.`); - return; - } - - const retPath = getSomePath(note); - - if (retPath) { - const noteTitle = getNoteTitleForPath(retPath); - - let branchId; - - if (note.isRoot()) { - branchId = 'none_root'; - } - else { - const parentNote = note.parents[0]; - branchId = becca.getBranchFromChildAndParent(noteId, parentNote.noteId).branchId; - } - - return { - noteId: noteId, - branchId: branchId, - title: noteTitle, - notePath: retPath, - path: retPath.join('/') - }; - } -} - -/** - * @param noteId - * @returns {boolean} - true if note exists (is not deleted) and is available in current note hoisting - */ -function isAvailable(noteId) { - const notePath = getNotePath(noteId); - - return !!notePath; -} - module.exports = { - getSomePath, - getNotePath, getNoteTitle, getNoteTitleForPath, - isAvailable, - isArchived, - isInAncestor, isNotePathArchived }; diff --git a/src/becca/entities/bnote.js b/src/becca/entities/bnote.js index 2e9febadb..b8e58c740 100644 --- a/src/becca/entities/bnote.js +++ b/src/becca/entities/bnote.js @@ -747,6 +747,21 @@ class BNote extends AbstractBeccaEntity { return this.hasAttribute('label', 'archived'); } + areAllNotePathsArchived() { + // there's a slight difference between note being itself archived and all its note paths being archived + // - note is archived when it itself has an archived label or inherits it + // - note does not have or inherit archived label, but each note paths contains a note with (non-inheritable) + // archived label + + const bestNotePathRecord = this.getSortedNotePathRecords()[0]; + + if (!bestNotePathRecord) { + throw new Error(`No note path available for note '${this.noteId}'`); + } + + return bestNotePathRecord.isArchived; + } + hasInheritableArchivedLabel() { for (const attr of this.getAttributes()) { if (attr.name === 'archived' && attr.type === LABEL && attr.isInheritable) { @@ -1150,6 +1165,8 @@ class BNote extends AbstractBeccaEntity { } /** + * Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles) + * * @returns {string[][]} - array of notePaths (each represented by array of noteIds constituting the particular note path) */ getAllNotePaths() { @@ -1157,18 +1174,73 @@ class BNote extends AbstractBeccaEntity { return [['root']]; } - const notePaths = []; + const parentNotes = this.getParentNotes(); + let notePaths = []; - for (const parentNote of this.getParentNotes()) { - for (const parentPath of parentNote.getAllNotePaths()) { - parentPath.push(this.noteId); - notePaths.push(parentPath); - } + if (parentNotes.length === 1) { // optimization for most common case + notePaths = parentNotes[0].getAllNotePaths(); + } else { + notePaths = parentNotes.flatMap(parentNote => parentNote.getAllNotePaths()); + } + + for (const notePath of notePaths) { + notePath.push(this.noteId); } return notePaths; } + /** + * @param {string} [hoistedNoteId='root'] + * @return {{isArchived: boolean, isInHoistedSubTree: boolean, notePath: string[], isHidden: boolean}[]} + */ + getSortedNotePathRecords(hoistedNoteId = 'root') { + const isHoistedRoot = hoistedNoteId === 'root'; + + const notePaths = this.getAllNotePaths().map(path => ({ + notePath: path, + isInHoistedSubTree: isHoistedRoot || path.includes(hoistedNoteId), + isArchived: path.some(noteId => this.becca.notes[noteId].isArchived), + isHidden: path.includes('_hidden') + })); + + notePaths.sort((a, b) => { + if (a.isInHoistedSubTree !== b.isInHoistedSubTree) { + return a.isInHoistedSubTree ? -1 : 1; + } else if (a.isArchived !== b.isArchived) { + return a.isArchived ? 1 : -1; + } else if (a.isHidden !== b.isHidden) { + return a.isHidden ? 1 : -1; + } else { + return a.notePath.length - b.notePath.length; + } + }); + + return notePaths; + } + + /** + * Returns note path considered to be the "best" + * + * @param {string} [hoistedNoteId='root'] + * @return {string[]} array of noteIds constituting the particular note path + */ + getBestNotePath(hoistedNoteId = 'root') { + return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath; + } + + /** + * Returns note path considered to be the "best" + * + * @param {string} [hoistedNoteId='root'] + * @return {string} serialized note path (e.g. 'root/a1h315/js725h') + */ + getBestNotePathString(hoistedNoteId = 'root') { + const notePath = this.getBestNotePath(hoistedNoteId); + + return notePath?.join("/"); + } + /** * @return boolean - true if there's no non-hidden path, note is not cloned to the visible tree */ diff --git a/src/becca/similarity.js b/src/becca/similarity.js index 2e7750100..8900bb87d 100644 --- a/src/becca/similarity.js +++ b/src/becca/similarity.js @@ -404,7 +404,7 @@ async function findSimilarNotes(noteId) { let score = computeScore(candidateNote); if (score >= 1.5) { - const notePath = beccaService.getSomePath(candidateNote); + const notePath = candidateNote.getBestNotePath(); // this takes care of note hoisting if (!notePath) { diff --git a/src/public/app/components/tab_manager.js b/src/public/app/components/tab_manager.js index 92e94b218..e5ca32c45 100644 --- a/src/public/app/components/tab_manager.js +++ b/src/public/app/components/tab_manager.js @@ -388,7 +388,12 @@ export default class TabManager extends Component { await this.triggerEvent('beforeNoteContextRemove', { ntxIds: ntxIdsToRemove }); if (!noteContextToRemove.isMainContext()) { - await this.activateNoteContext(noteContextToRemove.getMainContext().ntxId); + const siblings = noteContextToRemove.getMainContext().getSubContexts(); + const idx = siblings.findIndex(nc => nc.ntxId === noteContextToRemove.ntxId); + const contextToActivateIdx = idx === siblings.length - 1 ? idx - 1 : idx + 1; + const contextToActivate = siblings[contextToActivateIdx]; + + await this.activateNoteContext(contextToActivate.ntxId); } else if (this.mainNoteContexts.length <= 1) { await this.openAndActivateEmptyTab(); diff --git a/src/public/app/entities/fnote.js b/src/public/app/entities/fnote.js index 1066a9341..ad442e999 100644 --- a/src/public/app/entities/fnote.js +++ b/src/public/app/entities/fnote.js @@ -247,6 +247,11 @@ class FNote { return this.__filterAttrs(this.__getCachedAttributes([]), type, name); } + /** + * @param {string[]} path + * @return {FAttribute[]} + * @private + */ __getCachedAttributes(path) { // notes/clones cannot form tree cycles, it is possible to create attribute inheritance cycle via templates // when template instance is a parent of template itself @@ -299,63 +304,49 @@ class FNote { return this.noteId === 'root'; } - getAllNotePaths(encounteredNoteIds = null) { + /** + * Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles) + * + * @returns {string[][]} - array of notePaths (each represented by array of noteIds constituting the particular note path) + */ + getAllNotePaths() { if (this.noteId === 'root') { return [['root']]; } - if (!encounteredNoteIds) { - encounteredNoteIds = new Set(); - } - - encounteredNoteIds.add(this.noteId); - const parentNotes = this.getParentNotes(); - let paths; + let notePaths = []; - if (parentNotes.length === 1) { // optimization for the most common case - if (encounteredNoteIds.has(parentNotes[0].noteId)) { - return []; - } - else { - paths = parentNotes[0].getAllNotePaths(encounteredNoteIds); - } - } - else { - paths = []; - - for (const parentNote of parentNotes) { - if (encounteredNoteIds.has(parentNote.noteId)) { - continue; - } - - const newSet = new Set(encounteredNoteIds); - - paths.push(...parentNote.getAllNotePaths(newSet)); - } + if (parentNotes.length === 1) { // optimization for most common case + notePaths = parentNotes[0].getAllNotePaths(); + } else { + notePaths = parentNotes.flatMap(parentNote => parentNote.getAllNotePaths()); } - for (const path of paths) { - path.push(this.noteId); + for (const notePath of notePaths) { + notePath.push(this.noteId); } - return paths; + return notePaths; } - getSortedNotePaths(hoistedNotePath = 'root') { + /** + * @param {string} [hoistedNoteId='root'] + * @return {{isArchived: boolean, isInHoistedSubTree: boolean, notePath: string[], isHidden: boolean}[]} + */ + getSortedNotePathRecords(hoistedNoteId = 'root') { + const isHoistedRoot = hoistedNoteId === 'root'; + const notePaths = this.getAllNotePaths().map(path => ({ notePath: path, - isInHoistedSubTree: path.includes(hoistedNotePath), - isArchived: path.find(noteId => froca.notes[noteId].isArchived), - isSearch: path.find(noteId => froca.notes[noteId].type === 'search'), + isInHoistedSubTree: isHoistedRoot || path.includes(hoistedNoteId), + isArchived: path.some(noteId => froca.notes[noteId].isArchived), isHidden: path.includes('_hidden') })); notePaths.sort((a, b) => { if (a.isInHoistedSubTree !== b.isInHoistedSubTree) { return a.isInHoistedSubTree ? -1 : 1; - } else if (a.isSearch !== b.isSearch) { - return a.isSearch ? 1 : -1; } else if (a.isArchived !== b.isArchived) { return a.isArchived ? 1 : -1; } else if (a.isHidden !== b.isHidden) { @@ -368,6 +359,28 @@ class FNote { return notePaths; } + /** + * Returns note path considered to be the "best" + * + * @param {string} [hoistedNoteId='root'] + * @return {string[]} array of noteIds constituting the particular note path + */ + getBestNotePath(hoistedNoteId = 'root') { + return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath; + } + + /** + * Returns note path considered to be the "best" + * + * @param {string} [hoistedNoteId='root'] + * @return {string} serialized note path (e.g. 'root/a1h315/js725h') + */ + getBestNotePathString(hoistedNoteId = 'root') { + const notePath = this.getBestNotePath(hoistedNoteId); + + return notePath?.join("/"); + } + /** * @return boolean - true if there's no non-hidden path, note is not cloned to the visible tree */ @@ -391,6 +404,13 @@ class FNote { return true; } + /** + * @param {FAttribute[]} attributes + * @param {string} type + * @param {string} name + * @return {FAttribute[]} + * @private + */ __filterAttrs(attributes, type, name) { this.__validateTypeName(type, name); @@ -527,7 +547,9 @@ class FNote { * @returns {boolean} true if note has an attribute with given type and name (including inherited) */ hasAttribute(type, name) { - return !!this.getAttribute(type, name); + const attributes = this.getAttributes(); + + return attributes.some(attr => attr.name === name && attr.type === type); } /** diff --git a/src/public/app/services/branches.js b/src/public/app/services/branches.js index c414cf257..b0cf129a2 100644 --- a/src/public/app/services/branches.js +++ b/src/public/app/services/branches.js @@ -227,7 +227,7 @@ async function cloneNoteToBranch(childNoteId, parentBranchId, prefix) { } } -async function cloneNoteToNote(childNoteId, parentNoteId, prefix) { +async function cloneNoteToParentNote(childNoteId, parentNoteId, prefix) { const resp = await server.put(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, { prefix: prefix }); @@ -254,5 +254,5 @@ export default { moveNodeUpInHierarchy, cloneNoteAfter, cloneNoteToBranch, - cloneNoteToNote, + cloneNoteToParentNote, }; diff --git a/src/public/app/services/note_autocomplete.js b/src/public/app/services/note_autocomplete.js index 07c75d447..c548d6f0f 100644 --- a/src/public/app/services/note_autocomplete.js +++ b/src/public/app/services/note_autocomplete.js @@ -2,7 +2,6 @@ import server from "./server.js"; import appContext from "../components/app_context.js"; import utils from './utils.js'; import noteCreateService from './note_create.js'; -import treeService from './tree.js'; import froca from "./froca.js"; // this key needs to have this value, so it's hit by the tooltip @@ -188,7 +187,8 @@ function initNoteAutocomplete($el, options) { templateNoteId: templateNoteId }); - suggestion.notePath = treeService.getSomeNotePath(note); + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + suggestion.notePath = note.getBestNotePathString(hoistedNoteId); } $el.setSelectedNotePath(suggestion.notePath); diff --git a/src/public/app/services/note_tooltip.js b/src/public/app/services/note_tooltip.js index b83296cd8..ceff12b49 100644 --- a/src/public/app/services/note_tooltip.js +++ b/src/public/app/services/note_tooltip.js @@ -4,6 +4,7 @@ import froca from "./froca.js"; import utils from "./utils.js"; import attributeRenderer from "./attribute_renderer.js"; import noteContentRenderer from "./note_content_renderer.js"; +import appContext from "../components/app_context.js"; function setupGlobalTooltip() { $(document).on("mouseenter", "a", mouseEnterHandler); @@ -83,13 +84,14 @@ async function renderTooltip(note) { return '
Note has been deleted.
'; } - const someNotePath = treeService.getSomeNotePath(note); + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + const bestNotePath = note.getBestNotePathString(hoistedNoteId); - if (!someNotePath) { + if (!bestNotePath) { return; } - let content = `
${(await treeService.getNoteTitleWithPathAsSuffix(someNotePath)).prop('outerHTML')}
`; + let content = `
${(await treeService.getNoteTitleWithPathAsSuffix(bestNotePath)).prop('outerHTML')}
`; const {$renderedAttributes} = await attributeRenderer.renderNormalAttributes(note); diff --git a/src/public/app/services/tree.js b/src/public/app/services/tree.js index 3c4925f3e..88f13cdde 100644 --- a/src/public/app/services/tree.js +++ b/src/public/app/services/tree.js @@ -79,14 +79,10 @@ async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logEr You can ignore this message as it is mostly harmless.`); } - const someNotePath = getSomeNotePath(child, hoistedNoteId); + const bestNotePath = child.getBestNotePath(hoistedNoteId); - if (someNotePath) { // in case it's root the path may be empty - const pathToRoot = someNotePath.split("/").reverse().slice(1); - - if (!pathToRoot.includes("root")) { - pathToRoot.push('root'); - } + if (bestNotePath) { + const pathToRoot = bestNotePath.reverse().slice(1); for (const noteId of pathToRoot) { effectivePathSegments.push(noteId); @@ -109,31 +105,17 @@ async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logEr else { const note = await froca.getNote(getNoteIdFromNotePath(notePath)); - const someNotePathSegments = getSomeNotePathSegments(note, hoistedNoteId); + const bestNotePath = note.getBestNotePath(hoistedNoteId); - if (!someNotePathSegments) { - throw new Error(`Did not find any path segments for ${note.toString()}, hoisted note ${hoistedNoteId}`); + if (!bestNotePath) { + throw new Error(`Did not find any path segments for '${note.toString()}', hoisted note '${hoistedNoteId}'`); } // if there isn't actually any note path with hoisted note then return the original resolved note path - return someNotePathSegments.includes(hoistedNoteId) ? someNotePathSegments : effectivePathSegments; + return bestNotePath.includes(hoistedNoteId) ? bestNotePath : effectivePathSegments; } } -function getSomeNotePathSegments(note, hoistedNotePath = 'root') { - utils.assertArguments(note); - - const notePaths = note.getSortedNotePaths(hoistedNotePath); - - return notePaths.length > 0 ? notePaths[0].notePath : null; -} - -function getSomeNotePath(note, hoistedNotePath = 'root') { - const notePath = getSomeNotePathSegments(note, hoistedNotePath); - - return notePath === null ? null : notePath.join('/'); -} - ws.subscribeToMessages(message => { if (message.type === 'openNote') { appContext.tabManager.activateOrOpenNote(message.noteId); @@ -311,16 +293,6 @@ function isNotePathInAddress() { || (notePath === '' && !!ntxId); } -function parseNotePath(notePath) { - let noteIds = notePath.split('/'); - - if (noteIds[0] !== 'root') { - noteIds = ['root'].concat(noteIds); - } - - return noteIds; -} - function isNotePathInHiddenSubtree(notePath) { return notePath?.includes("root/_hidden"); } @@ -328,8 +300,6 @@ function isNotePathInHiddenSubtree(notePath) { export default { resolveNotePath, resolveNotePathToSegments, - getSomeNotePath, - getSomeNotePathSegments, getParentProtectedStatus, getNotePath, getNoteIdFromNotePath, @@ -340,6 +310,5 @@ export default { getNoteTitleWithPathAsSuffix, getHashValueFromAddress, isNotePathInAddress, - parseNotePath, isNotePathInHiddenSubtree }; diff --git a/src/public/app/widgets/attribute_widgets/attribute_detail.js b/src/public/app/widgets/attribute_widgets/attribute_detail.js index 80efc31ba..dba64da70 100644 --- a/src/public/app/widgets/attribute_widgets/attribute_detail.js +++ b/src/public/app/widgets/attribute_widgets/attribute_detail.js @@ -1,6 +1,5 @@ import server from "../../services/server.js"; import froca from "../../services/froca.js"; -import treeService from "../../services/tree.js"; import linkService from "../../services/link.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; import noteAutocompleteService from "../../services/note_autocomplete.js"; @@ -9,6 +8,7 @@ import NoteContextAwareWidget from "../note_context_aware_widget.js"; import SpacedUpdate from "../../services/spaced_update.js"; import utils from "../../services/utils.js"; import shortcutService from "../../services/shortcuts.js"; +import appContext from "../../components/app_context.js"; const TPL = `
@@ -598,9 +598,10 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { const displayedResults = results.length <= DISPLAYED_NOTES ? results : results.slice(0, DISPLAYED_NOTES); const displayedNotes = await froca.getNotes(displayedResults.map(res => res.noteId)); + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; for (const note of displayedNotes) { - const notePath = treeService.getSomeNotePath(note); + const notePath = note.getBestNotePathString(hoistedNoteId); const $noteLink = await linkService.createNoteLink(notePath, {showNotePath: true}); this.$relatedNotesList.append( diff --git a/src/public/app/widgets/attribute_widgets/attribute_editor.js b/src/public/app/widgets/attribute_widgets/attribute_editor.js index 217717f05..88d3e837a 100644 --- a/src/public/app/widgets/attribute_widgets/attribute_editor.js +++ b/src/public/app/widgets/attribute_widgets/attribute_editor.js @@ -7,7 +7,6 @@ import libraryLoader from "../../services/library_loader.js"; import froca from "../../services/froca.js"; import attributeRenderer from "../../services/attribute_renderer.js"; import noteCreateService from "../../services/note_create.js"; -import treeService from "../../services/tree.js"; import attributeService from "../../services/attributes.js"; const HELP_TEXT = ` @@ -503,7 +502,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget { title: title }); - return treeService.getSomeNotePath(note); + return note.getBestNotePathString(); } async updateAttributeList(attributes) { diff --git a/src/public/app/widgets/dialogs/recent_changes.js b/src/public/app/widgets/dialogs/recent_changes.js index 75b477276..21d38d016 100644 --- a/src/public/app/widgets/dialogs/recent_changes.js +++ b/src/public/app/widgets/dialogs/recent_changes.js @@ -1,7 +1,6 @@ import linkService from '../../services/link.js'; import utils from '../../services/utils.js'; import server from '../../services/server.js'; -import treeService from "../../services/tree.js"; import froca from "../../services/froca.js"; import appContext from "../../components/app_context.js"; import hoistedNoteService from "../../services/hoisted_note.js"; @@ -108,7 +107,7 @@ export default class RecentChangesDialog extends BasicWidget { } } else { const note = await froca.getNote(change.noteId); - const notePath = treeService.getSomeNotePath(note); + const notePath = note.getBestNotePathString(); if (notePath) { $noteLink = await linkService.createNoteLink(notePath, { diff --git a/src/public/app/widgets/note_tree.js b/src/public/app/widgets/note_tree.js index 7837672d2..d438bba4b 100644 --- a/src/public/app/widgets/note_tree.js +++ b/src/public/app/widgets/note_tree.js @@ -223,7 +223,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return false; }); - this.$treeSettingsPopup.on("click", e => { e.stopPropagation(); }); + this.$treeSettingsPopup.on("click", e => {e.stopPropagation();}); $(document).on('click', () => this.$treeSettingsPopup.hide()); @@ -251,12 +251,12 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // code inspired by https://gist.github.com/jtsternberg/c272d7de5b967cec2d3d const isEnclosing = ($container, $sub) => { - const conOffset = $container.offset(); - const conDistanceFromTop = conOffset.top + $container.outerHeight(true); + const conOffset = $container.offset(); + const conDistanceFromTop = conOffset.top + $container.outerHeight(true); const conDistanceFromLeft = conOffset.left + $container.outerWidth(true); - const subOffset = $sub.offset(); - const subDistanceFromTop = subOffset.top + $sub.outerHeight(true); + const subOffset = $sub.offset(); + const subDistanceFromTop = subOffset.top + $sub.outerHeight(true); const subDistanceFromLeft = subOffset.left + $sub.outerWidth(true); return conDistanceFromTop > subDistanceFromTop @@ -673,7 +673,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return noteList; } - updateNode(node) { + async updateNode(node) { const note = froca.getNoteFromCache(node.data.noteId); const branch = froca.getBranch(node.data.branchId); @@ -697,7 +697,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { node.title = utils.escapeHtml(title); if (node.isExpanded() !== branch.isExpanded) { - node.setExpanded(branch.isExpanded, {noEvents: true, noAnimation: true}); + await node.setExpanded(branch.isExpanded, {noEvents: true, noAnimation: true}); } node.renderTitle(); @@ -849,7 +849,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { await this.setExpandedStatusForSubtree(node, false); } - collapseTreeEvent() { this.collapseTree(); } + collapseTreeEvent() {this.collapseTree();} /** * @returns {FancytreeNode|null} @@ -920,7 +920,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } if (expand) { - await parentNode.setExpanded(true, {noAnimation: true}); + if (!parentNode.isExpanded()) { + await parentNode.setExpanded(true, {noAnimation: true}); + } // although previous line should set the expanded status, it seems to happen asynchronously, // so we need to make sure it is set properly before calling updateNode which uses this flag @@ -928,7 +930,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { branch.isExpanded = true; } - this.updateNode(parentNode); + await this.updateNode(parentNode); let foundChildNode = this.findChildNode(parentNode, childNoteId); @@ -1096,10 +1098,10 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const activeNode = this.getActiveNode(); const activeNodeFocused = activeNode && activeNode.hasFocus(); const nextNode = activeNode ? (activeNode.getNextSibling() || activeNode.getPrevSibling() || activeNode.getParent()) : null; - const activeNotePath = activeNode ? treeService.getNotePath(activeNode) : null; + let activeNotePath = activeNode ? treeService.getNotePath(activeNode) : null; const nextNotePath = nextNode ? treeService.getNotePath(nextNode) : null; - const activeNoteId = activeNode ? activeNode.data.noteId : null; + let activeNoteId = activeNode ? activeNode.data.noteId : null; const noteIdsToUpdate = new Set(); const noteIdsToReload = new Set(); @@ -1142,7 +1144,14 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - for (const ecBranch of loadResults.getBranches()) { + // activeNode is supposed to be moved when we find out activeNode is deleted but not all branches are deleted. save it for fixing activeNodePath after all nodes loaded. + let movedActiveNode = null; + let parentsOfAddedNodes = []; + + const allBranches = loadResults.getBranches(); + const allBranchesDeleted = allBranches.every(branch => !!branch.isDeleted); + + for (const ecBranch of allBranches) { if (ecBranch.parentNoteId === '_share') { // all shared notes have a sign in the tree, even the descendants of shared notes noteIdsToReload.add(ecBranch.noteId); @@ -1155,12 +1164,16 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { for (const node of this.getNodesByBranch(ecBranch)) { if (ecBranch.isDeleted) { if (node.isActive()) { - const newActiveNode = node.getNextSibling() - || node.getPrevSibling() - || node.getParent(); + if (allBranchesDeleted) { + const newActiveNode = node.getNextSibling() + || node.getPrevSibling() + || node.getParent(); - if (newActiveNode) { - newActiveNode.setActive(true, {noEvents: true, noFocus: true}); + if (newActiveNode) { + newActiveNode.setActive(true, {noEvents: true, noFocus: true}); + } + } else { + movedActiveNode = node; } } @@ -1174,12 +1187,13 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { if (!ecBranch.isDeleted) { for (const parentNode of this.getNodesByNoteId(ecBranch.parentNoteId)) { + parentsOfAddedNodes.push(parentNode) + if (parentNode.isFolder() && !parentNode.isLoaded()) { continue; } const found = (parentNode.getChildren() || []).find(child => child.data.noteId === ecBranch.noteId); - if (!found) { // make sure it's loaded await froca.getNote(ecBranch.noteId); @@ -1222,7 +1236,18 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // for some reason node update cannot be in the batchUpdate() block (node is not re-rendered) for (const noteId of noteIdsToUpdate) { for (const node of this.getNodesByNoteId(noteId)) { - this.updateNode(node); + await this.updateNode(node); + } + } + + if (movedActiveNode) { + for (const parentNode of parentsOfAddedNodes) { + const found = (parentNode.getChildren() || []).find(child => child.data.noteId === movedActiveNode.data.noteId); + if (found) { + activeNotePath = treeService.getNotePath(found); + activeNoteId = found.data.noteId; + break + } } } diff --git a/src/public/app/widgets/ribbon_widgets/note_paths.js b/src/public/app/widgets/ribbon_widgets/note_paths.js index 3270e0eb4..7a6977b17 100644 --- a/src/public/app/widgets/ribbon_widgets/note_paths.js +++ b/src/public/app/widgets/ribbon_widgets/note_paths.js @@ -72,7 +72,7 @@ export default class NotePathsWidget extends NoteContextAwareWidget { return; } - const sortedNotePaths = this.note.getSortedNotePaths(this.hoistedNoteId) + const sortedNotePaths = this.note.getSortedNotePathRecords(this.hoistedNoteId) .filter(notePath => !notePath.isHidden); if (sortedNotePaths.length > 0) { diff --git a/src/public/app/widgets/shared_switch.js b/src/public/app/widgets/shared_switch.js index 61dd140db..a7aaa1e52 100644 --- a/src/public/app/widgets/shared_switch.js +++ b/src/public/app/widgets/shared_switch.js @@ -25,7 +25,7 @@ export default class SharedSwitchWidget extends SwitchWidget { } async switchOn() { - await branchService.cloneNoteToNote(this.noteId, '_share'); + await branchService.cloneNoteToParentNote(this.noteId, '_share'); syncService.syncNow(true); } diff --git a/src/public/app/widgets/type_widgets/editable_code.js b/src/public/app/widgets/type_widgets/editable_code.js index abb55b36b..a183601ce 100644 --- a/src/public/app/widgets/type_widgets/editable_code.js +++ b/src/public/app/widgets/type_widgets/editable_code.js @@ -53,7 +53,7 @@ export default class EditableCodeTypeWidget extends TypeWidget { matchBrackets: true, keyMap: options.is('vimKeymapEnabled') ? "vim": "default", matchTags: {bothTags: true}, - highlightSelectionMatches: {showToken: /\w/, annotateScrollbar: false}, + highlightSelectionMatches: {showToken: false, annotateScrollbar: false}, lint: true, gutters: ["CodeMirror-lint-markers"], lineNumbers: true, @@ -62,7 +62,7 @@ export default class EditableCodeTypeWidget extends TypeWidget { // all the way to the bottom of the note. With line wrap there's no horizontal scrollbar so no problem lineWrapping: options.is('codeLineWrapEnabled'), dragDrop: false, // with true the editor inlines dropped files which is not what we expect - placeholder: "Type the content of your code note here..." + placeholder: "Type the content of your code note here...", }); this.codeEditor.on('change', () => this.spacedUpdate.scheduleUpdate()); diff --git a/src/public/app/widgets/type_widgets/editable_text.js b/src/public/app/widgets/type_widgets/editable_text.js index aa1015242..2c6cf55f2 100644 --- a/src/public/app/widgets/type_widgets/editable_text.js +++ b/src/public/app/widgets/type_widgets/editable_text.js @@ -4,7 +4,6 @@ import mimeTypesService from '../../services/mime_types.js'; import utils from "../../services/utils.js"; import keyboardActionService from "../../services/keyboard_actions.js"; import froca from "../../services/froca.js"; -import treeService from "../../services/tree.js"; import noteCreateService from "../../services/note_create.js"; import AbstractTextTypeWidget from "./abstract_text_type_widget.js"; import link from "../../services/link.js"; @@ -378,7 +377,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { return; } - return treeService.getSomeNotePath(resp.note); + return resp.note.getBestNotePathString(); } async refreshIncludedNoteEvent({noteId}) { diff --git a/src/routes/api/cloning.js b/src/routes/api/cloning.js index da557715c..75a42e675 100644 --- a/src/routes/api/cloning.js +++ b/src/routes/api/cloning.js @@ -9,11 +9,11 @@ function cloneNoteToBranch(req) { return cloningService.cloneNoteToBranch(noteId, parentBranchId, prefix); } -function cloneNoteToNote(req) { +function cloneNoteToParentNote(req) { const {noteId, parentNoteId} = req.params; const {prefix} = req.body; - return cloningService.cloneNoteToNote(noteId, parentNoteId, prefix); + return cloningService.cloneNoteToParentNote(noteId, parentNoteId, prefix); } function cloneNoteAfter(req) { @@ -30,7 +30,7 @@ function toggleNoteInParent(req) { module.exports = { cloneNoteToBranch, - cloneNoteToNote, + cloneNoteToParentNote, cloneNoteAfter, toggleNoteInParent }; diff --git a/src/routes/api/note_revisions.js b/src/routes/api/note_revisions.js index 7448eb7da..f28239f2e 100644 --- a/src/routes/api/note_revisions.js +++ b/src/routes/api/note_revisions.js @@ -135,7 +135,7 @@ function getEditedNotesOnDate(req) { notes = notes.map(note => note.getPojo()); for (const note of notes) { - const notePath = note.isDeleted ? null : beccaService.getNotePath(note.noteId); + const notePath = note.isDeleted ? null : getNotePathData(note); note.notePath = notePath ? notePath.notePath : null; } @@ -143,6 +143,32 @@ function getEditedNotesOnDate(req) { return notes; } +function getNotePathData(note) { + const retPath = note.getBestNotePath(); + + if (retPath) { + const noteTitle = beccaService.getNoteTitleForPath(retPath); + + let branchId; + + if (note.isRoot()) { + branchId = 'none_root'; + } + else { + const parentNote = note.parents[0]; + branchId = becca.getBranchFromChildAndParent(note.noteId, parentNote.noteId).branchId; + } + + return { + noteId: note.noteId, + branchId: branchId, + title: noteTitle, + notePath: retPath, + path: retPath.join('/') + }; + } +} + module.exports = { getNoteRevisions, getNoteRevision, diff --git a/src/routes/api/recent_changes.js b/src/routes/api/recent_changes.js index 2d54af93b..646898e9c 100644 --- a/src/routes/api/recent_changes.js +++ b/src/routes/api/recent_changes.js @@ -3,14 +3,14 @@ const sql = require('../../services/sql'); const protectedSessionService = require('../../services/protected_session'); const noteService = require('../../services/notes'); -const beccaService = require('../../becca/becca_service'); +const becca = require("../../becca/becca"); function getRecentChanges(req) { const {ancestorNoteId} = req.params; let recentChanges = []; - const noteRevisions = sql.getRows(` + const noteRevisionRows = sql.getRows(` SELECT notes.noteId, notes.isDeleted AS current_isDeleted, @@ -24,16 +24,18 @@ function getRecentChanges(req) { note_revisions JOIN notes USING(noteId)`); - for (const noteRevision of noteRevisions) { - if (beccaService.isInAncestor(noteRevision.noteId, ancestorNoteId)) { - recentChanges.push(noteRevision); + for (const noteRevisionRow of noteRevisionRows) { + const note = becca.getNote(noteRevisionRow.noteId); + + if (note?.hasAncestor(ancestorNoteId)) { + recentChanges.push(noteRevisionRow); } } // now we need to also collect date points not represented in note revisions: // 1. creation for all notes (dateCreated) // 2. deletion for deleted notes (dateModified) - const notes = sql.getRows(` + const noteRows = sql.getRows(` SELECT notes.noteId, notes.isDeleted AS current_isDeleted, @@ -57,9 +59,11 @@ function getRecentChanges(req) { FROM notes WHERE notes.isDeleted = 1`); - for (const note of notes) { - if (beccaService.isInAncestor(note.noteId, ancestorNoteId)) { - recentChanges.push(note); + for (const noteRow of noteRows) { + const note = becca.getNote(noteRow.noteId); + + if (note?.hasAncestor(ancestorNoteId)) { + recentChanges.push(noteRow); } } diff --git a/src/routes/routes.js b/src/routes/routes.js index 9cc87bb37..a52a067f5 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -143,7 +143,7 @@ function register(app) { apiRoute(PUT, '/api/notes/:noteId/clone-to-branch/:parentBranchId', cloningApiRoute.cloneNoteToBranch); apiRoute(PUT, '/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present', cloningApiRoute.toggleNoteInParent); - apiRoute(PUT, '/api/notes/:noteId/clone-to-note/:parentNoteId', cloningApiRoute.cloneNoteToNote); + apiRoute(PUT, '/api/notes/:noteId/clone-to-note/:parentNoteId', cloningApiRoute.cloneNoteToParentNote); apiRoute(PUT, '/api/notes/:noteId/clone-after/:afterBranchId', cloningApiRoute.cloneNoteAfter); route(GET, '/api/notes/:branchId/export/:type/:format/:version/:taskId', [auth.checkApiAuthOrElectron], exportRoute.exportBranch); diff --git a/src/services/bulk_actions.js b/src/services/bulk_actions.js index eaa57a1fb..c51ba1d5e 100644 --- a/src/services/bulk_actions.js +++ b/src/services/bulk_actions.js @@ -83,7 +83,7 @@ const ACTION_HANDLERS = { let res; if (note.getParentBranches().length > 1) { - res = cloningService.cloneNoteToNote(note.noteId, action.targetParentNoteId); + res = cloningService.cloneNoteToParentNote(note.noteId, action.targetParentNoteId); } else { res = branchService.moveBranchToNote(note.getParentBranches()[0], action.targetParentNoteId); diff --git a/src/services/cloning.js b/src/services/cloning.js index dad3d3906..f62b8724b 100644 --- a/src/services/cloning.js +++ b/src/services/cloning.js @@ -8,7 +8,7 @@ const becca = require("../becca/becca"); const beccaService = require("../becca/becca_service"); const log = require("./log"); -function cloneNoteToNote(noteId, parentNoteId, prefix) { +function cloneNoteToParentNote(noteId, parentNoteId, prefix) { const parentNote = becca.getNote(parentNoteId); if (parentNote.type === 'search') { @@ -19,7 +19,7 @@ function cloneNoteToNote(noteId, parentNoteId, prefix) { } if (isNoteDeleted(noteId) || isNoteDeleted(parentNoteId)) { - return { success: false, message: 'Note is deleted.' }; + return { success: false, message: 'Note cannot be cloned because either the cloned note or the intended parent is deleted.' }; } const validationResult = treeService.validateParentChild(parentNoteId, noteId); @@ -35,12 +35,12 @@ function cloneNoteToNote(noteId, parentNoteId, prefix) { isExpanded: 0 }).save(); - log.info(`Cloned note '${noteId}' to new parent note '${parentNoteId}' with prefix '${prefix}'`); + log.info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`); return { success: true, branchId: branch.branchId, - notePath: `${beccaService.getNotePath(parentNoteId).path}/${noteId}` + notePath: `${parentNote.getBestNotePathString()}/${noteId}` }; } @@ -51,7 +51,7 @@ function cloneNoteToBranch(noteId, parentBranchId, prefix) { return { success: false, message: `Parent branch ${parentBranchId} does not exist.` }; } - const ret = cloneNoteToNote(noteId, parentBranch.noteId, prefix); + const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix); parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user parentBranch.save(); @@ -182,7 +182,7 @@ function isNoteDeleted(noteId) { module.exports = { cloneNoteToBranch, - cloneNoteToNote, + cloneNoteToParentNote, ensureNoteIsPresentInParent, ensureNoteIsAbsentFromParent, toggleNoteInParent, diff --git a/src/services/search/expressions/note_flat_text.js b/src/services/search/expressions/note_flat_text.js index 863573a13..62985f590 100644 --- a/src/services/search/expressions/note_flat_text.js +++ b/src/services/search/expressions/note_flat_text.js @@ -22,9 +22,9 @@ class NoteFlatTextExp extends Expression { * @param {string[]} tokens * @param {string[]} path */ - function searchDownThePath(note, tokens, path) { + const searchDownThePath = (note, tokens, path) => { if (tokens.length === 0) { - const retPath = beccaService.getSomePath(note, path); + const retPath = this.getNotePath(note, path); if (retPath) { const noteId = retPath[retPath.length - 1]; @@ -131,6 +131,17 @@ class NoteFlatTextExp extends Expression { return resultNoteSet; } + getNotePath(note, path) { + if (path.length === 0) { + return note.getBestNotePath(); + } else { + const closestNoteId = path[0]; + const closestNoteBestNotePath = becca.getNote(closestNoteId).getBestNotePath(); + + return [...closestNoteBestNotePath, ...path.slice(1)]; + } + } + /** * Returns noteIds which have at least one matching tokens * diff --git a/src/services/search/services/search.js b/src/services/search/services/search.js index f1adc72d7..3e36040ab 100644 --- a/src/services/search/services/search.js +++ b/src/services/search/services/search.js @@ -157,7 +157,7 @@ function findResultsWithExpression(expression, searchContext) { const searchResults = noteSet.notes .filter(note => !note.isDeleted) .map(note => { - const notePathArray = executionContext.noteIdToNotePath[note.noteId] || beccaService.getSomePath(note); + const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath(); if (!notePathArray) { throw new Error(`Can't find note path for note ${JSON.stringify(note.getPojo())}`); diff --git a/src/services/ws.js b/src/services/ws.js index ac81c367d..f019691c3 100644 --- a/src/services/ws.js +++ b/src/services/ws.js @@ -9,6 +9,18 @@ const protectedSessionService = require('./protected_session'); const becca = require("../becca/becca"); const AbstractBeccaEntity = require("../becca/entities/abstract_becca_entity"); +const env = require('./env'); +if (env.isDev()) { + const chokidar = require('chokidar'); + const debounce = require('debounce'); + const debouncedReloadFrontend = debounce(reloadFrontend, 200); + chokidar + .watch('src/public') + .on('add', debouncedReloadFrontend) + .on('change', debouncedReloadFrontend) + .on('unlink', debouncedReloadFrontend); +} + let webSocketServer; let lastSyncedPush = null; diff --git a/webpack-desktop.config.js b/webpack-desktop.config.js deleted file mode 100644 index 8fbf0e152..000000000 --- a/webpack-desktop.config.js +++ /dev/null @@ -1,16 +0,0 @@ -const path = require('path'); -const assetPath = require('./src/services/asset_path'); - -module.exports = { - mode: 'production', - entry: { - mobile: './src/public/app/desktop.js', - }, - output: { - publicPath: `${assetPath}/app-dist/`, - path: path.resolve(__dirname, 'src/public/app-dist'), - filename: 'desktop.js' - }, - devtool: 'source-map', - target: 'electron-renderer' -}; diff --git a/webpack-setup.config.js b/webpack-setup.config.js deleted file mode 100644 index dee04f090..000000000 --- a/webpack-setup.config.js +++ /dev/null @@ -1,16 +0,0 @@ -const path = require('path'); -const assetPath = require('./src/services/asset_path'); - -module.exports = { - mode: 'production', - entry: { - mobile: './src/public/app/setup.js', - }, - output: { - publicPath: `${assetPath}/app-dist/`, - path: path.resolve(__dirname, 'src/public/app-dist'), - filename: 'setup.js' - }, - devtool: 'source-map', - target: 'electron-renderer' -}; diff --git a/webpack-mobile.config.js b/webpack.config.js similarity index 69% rename from webpack-mobile.config.js rename to webpack.config.js index 4fc72b8be..41077c00e 100644 --- a/webpack-mobile.config.js +++ b/webpack.config.js @@ -4,13 +4,15 @@ const assetPath = require('./src/services/asset_path'); module.exports = { mode: 'production', entry: { + setup: './src/public/app/setup.js', mobile: './src/public/app/mobile.js', + desktop: './src/public/app/desktop.js', }, output: { publicPath: `${assetPath}/app-dist/`, path: path.resolve(__dirname, 'src/public/app-dist'), - filename: 'mobile.js' + filename: '[name].js', }, devtool: 'source-map', - target: 'electron-renderer' + target: 'electron-renderer', };