diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..cf778bb2d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +node_modules +dist +bin +docs +libraries +coverage +play diff --git a/.eslintrc.js b/.eslintrc.js index c554a263c..9ed9faf1f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,16 +1,213 @@ module.exports = { - "env": { - "browser": true, - "commonjs": true, - "es2021": true, - "node": true + env: { + browser: true, + commonjs: true, + es2021: true, + node: true, }, - "extends": "eslint:recommended", - "overrides": [ + // plugins: ['prettier'], // to be activated + extends: ['eslint:recommended', 'airbnb-base', 'plugin:jsonc/recommended-with-jsonc', 'prettier'], + overrides: [ + { + files: ['*.json', '*.json5', '*.jsonc'], + parser: 'jsonc-eslint-parser', + }, + { + files: ['package.json'], + parser: 'jsonc-eslint-parser', + rules: { + 'jsonc/sort-keys': [ + 'off', + { + pathPattern: '^$', + order: [ + 'name', + 'version', + 'private', + 'packageManager', + 'description', + 'type', + 'keywords', + 'homepage', + 'bugs', + 'license', + 'author', + 'contributors', + 'funding', + 'files', + 'main', + 'module', + 'exports', + 'unpkg', + 'jsdelivr', + 'browser', + 'bin', + 'man', + 'directories', + 'repository', + 'publishConfig', + 'scripts', + 'peerDependencies', + 'peerDependenciesMeta', + 'optionalDependencies', + 'dependencies', + 'devDependencies', + 'engines', + 'config', + 'overrides', + 'pnpm', + 'husky', + 'lint-staged', + 'eslintConfig', + ], + }, + { + pathPattern: '^(?:dev|peer|optional|bundled)?[Dd]ependencies$', + order: { type: 'asc' }, + }, + ], + }, + }, ], - "parserOptions": { - "ecmaVersion": "latest" + globals: { + $: true, + jQuery: true, + glob: true, + log: true, + EditorWatchdog: true, + baseApiUrl: true, + // \src\share\canvas_share.js + React: true, + appState: true, + ExcalidrawLib: true, + elements: true, + files: true, + ReactDOM: true, + // src\public\app\widgets\type_widgets\relation_map.js + jsPlumb: true, + panzoom: true, + logError: true, + // src\public\app\widgets\type_widgets\image.js + WZoom: true, + // \src\public\app\widgets\type_widgets\read_only_text.js + renderMathInElement: true, + // \src\public\app\widgets\type_widgets\editable_text.js + BalloonEditor: true, + CKEditorInspector: true, + // \src\public\app\widgets\type_widgets\editable_code.js + CodeMirror: true, + // \src\public\app\services\resizer.js + Split: true, + // \src\public\app\services\note_content_renderer.js + mermaid: true, + // src\public\app\services\frontend_script_api.js + dayjs: true, + // \src\public\app\widgets\dialogs\markdown_import.js + commonmark: true, + // \src\public\app\widgets\note_map.js + ForceGraph: true, + // \src\public\app\setup.js + ko: true, + syncInProgress: true, + // src\public\app\services\utils.js + logInfo: true, + __non_webpack_require__: true, + // }, - "rules": { - } -} + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + // eslint:recommended + 'no-unused-vars': 'off', + 'linebreak-style': 'off', + 'no-useless-escape': 'off', + 'no-empty': 'off', + 'no-constant-condition': 'off', + 'getter-return': 'off', + 'no-cond-assign': 'off', + 'no-async-promise-executor': 'off', + 'no-extra-semi': 'off', + 'no-inner-declarations': 'off', + + // prettier + 'prettier/prettier': ['off', { endOfLine: 'auto' }], + + // airbnb-base + 'no-console': 'off', + 'no-plusplus': 'off', + 'no-param-reassign': 'off', + 'global-require': 'off', + 'no-use-before-define': 'off', + 'no-await-in-loop': 'off', + radix: 'off', + 'import/order': 'off', + 'import/no-extraneous-dependencies': 'off', + 'prefer-destructuring': 'off', + 'no-shadow': 'off', + 'no-new': 'off', + 'no-restricted-syntax': 'off', + strict: 'off', + 'class-methods-use-this': 'off', + 'no-else-return': 'off', + 'import/no-dynamic-require': 'off', + 'no-underscore-dangle': 'off', + 'prefer-template': 'off', + 'consistent-return': 'off', + 'no-continue': 'off', + 'object-shorthand': 'off', + 'one-var': 'off', + 'prefer-const': 'off', + 'spaced-comment': 'off', + 'no-loop-func': 'off', + 'arrow-body-style': 'off', + + 'guard-for-in': 'off', + 'no-return-assign': 'off', + 'dot-notation': 'off', + + 'func-names': 'off', + 'import/no-useless-path-segments': 'off', + 'default-param-last': 'off', + 'prefer-arrow-callback': 'off', + 'no-unneeded-ternary': 'off', + 'no-return-await': 'off', + 'import/extensions': 'off', + + 'no-var': 'off', + 'import/newline-after-import': 'off', + 'no-restricted-globals': 'off', + 'operator-assignment': 'off', + 'no-eval': 'off', + 'max-classes-per-file': 'off', + 'vars-on-top': 'off', + 'no-bitwise': 'off', + 'no-lonely-if': 'off', + 'no-multi-assign': 'off', + 'no-promise-executor-return': 'off', + 'no-empty-function': 'off', + 'import/no-unresolved': 'off', + camelcase: 'off', + eqeqeq: 'off', + 'lines-between-class-members': 'off', + 'import/no-cycle': 'off', + 'new-cap': 'off', + 'prefer-object-spread': 'off', + 'no-new-func': 'off', + 'no-unused-expressions': 'off', + 'lines-around-directive': 'off', + 'prefer-exponentiation-operator': 'off', + 'no-restricted-properties': 'off', + 'prefer-rest-params': 'off', + 'no-unreachable-loop': 'off', + 'no-alert': 'off', + 'no-useless-return': 'off', + 'no-nested-ternary': 'off', + 'prefer-regex-literals': 'off', + 'import/no-named-as-default-member': 'off', + yoda: 'off', + 'no-script-url': 'off', + 'no-prototype-builtins':'off' + }, +}; diff --git a/.gitignore b/.gitignore index 01eac268c..6c7f73ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ server-package.json .idea/httpRequests/ data/ tmp/ +.eslintcache \ No newline at end of file diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 000000000..31354ec13 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..d5b5fd41c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +#npx lint-staged diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 000000000..e2f476b94 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,11 @@ +//https://prettier.io/docs/en/options.html +module.exports = { + semi: true, + trailingComma: 'es5', + singleQuote: true, + printWidth: 120, + tabWidth: 4, + // useTabs: false, + // bracketSpacing: true, + // htmlWhitespaceSensitivity: 'ignore', +}; diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..b22b867bd --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..0e4b77b5c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,33 @@ +{ + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[json]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "editor.formatOnSave": true, + "eslint.format.enable": true, + "eslint.probe": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "html", + "vue", + "markdown", + "json", + "jsonc" + ], + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "html", + "vue", + "markdown", + "json", + "jsonc" + ], + "files.eol": "\n", +} \ No newline at end of file diff --git a/docker_healthcheck.js b/docker_healthcheck.js index 2602cd706..9213c401f 100755 --- a/docker_healthcheck.js +++ b/docker_healthcheck.js @@ -8,7 +8,6 @@ if (config.https) { // built-in TLS (terminated by trilium) is not supported yet, PRs are welcome // for reverse proxy terminated TLS this will works since config.https will be false process.exit(0); - return; } const port = require('./src/services/port'); 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 eff8721e1..3be963b57 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "trilium", "productName": "Trilium Notes", "description": "Trilium Notes", - "version": "0.59.3", + "version": "0.59.4", "license": "AGPL-3.0-only", "main": "electron.js", "bin": { @@ -13,20 +13,22 @@ "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", - "postinstall": "rimraf ./node_modules/canvas" + "postinstall": "rimraf ./node_modules/canvas", + "lint": "eslint . --cache", + "prepare": "husky install" }, "dependencies": { "@braintree/sanitize-url": "6.0.2", @@ -100,15 +102,28 @@ "electron-packager": "17.1.1", "electron-rebuild": "3.2.9", "eslint": "^8.38.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsonc": "^2.7.0", + "eslint-plugin-prettier": "^4.2.1", "esm": "3.2.25", + "husky": "^8.0.3", + "jsonc-eslint-parser": "^2.2.0", + "lint-staged": "^13.2.1", "jasmine": "4.6.0", "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" }, "optionalDependencies": { "electron-installer-debian": "3.1.0" + }, + "lint-staged": { + "*.js": "eslint --cache --fix" } } 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 8c87dc09e..6139f7fa7 100644 --- a/src/becca/entities/bnote.js +++ b/src/becca/entities/bnote.js @@ -688,6 +688,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) { @@ -1118,6 +1133,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() { @@ -1125,18 +1142,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 a2b9dd991..73eee6f74 100644 --- a/src/public/app/components/tab_manager.js +++ b/src/public/app/components/tab_manager.js @@ -413,7 +413,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 fd3949d92..0187d7db5 100644 --- a/src/public/app/entities/fnote.js +++ b/src/public/app/entities/fnote.js @@ -268,6 +268,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 @@ -320,63 +325,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) { @@ -389,6 +380,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 */ @@ -412,6 +425,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); @@ -541,7 +561,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/froca_updater.js b/src/public/app/services/froca_updater.js index 1e09a0bbb..f6b5f35b8 100644 --- a/src/public/app/services/froca_updater.js +++ b/src/public/app/services/froca_updater.js @@ -140,7 +140,7 @@ async function processBranchChange(loadResults, ec) { const childNote = froca.notes[ec.entity.noteId]; let parentNote = froca.notes[ec.entity.parentNoteId]; - if (childNote && !parentNote) { + if (childNote && !childNote.isRoot() && !parentNote) { // a branch cannot exist without the parent // a note loaded into froca has to also contain all its ancestors // this problem happened e.g. in sharing where _share was hidden and thus not loaded 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 c16620c4c..f0cf15651 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); @@ -77,13 +78,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 2cc5803d9..60e862f97 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); @@ -341,16 +323,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"); } @@ -358,8 +330,6 @@ function isNotePathInHiddenSubtree(notePath) { export default { resolveNotePath, resolveNotePathToSegments, - getSomeNotePath, - getSomeNotePathSegments, getParentProtectedStatus, getNotePath, getNoteIdFromNotePath, @@ -370,6 +340,5 @@ export default { getNoteTitleWithPathAsSuffix, parseNavigationStateFromAddress, 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 bb69f3b50..1c4b9a3a1 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/note_revisions.js b/src/public/app/widgets/dialogs/note_revisions.js index dbcca9b2e..bdead4209 100644 --- a/src/public/app/widgets/dialogs/note_revisions.js +++ b/src/public/app/widgets/dialogs/note_revisions.js @@ -240,7 +240,7 @@ export default class NoteRevisionsDialog extends BasicWidget { if (this.$content.find('span.math-tex').length > 0) { await libraryLoader.requireLibrary(libraryLoader.KATEX); - renderMathInElement($content[0], {trust: true}); + renderMathInElement(this.$content[0], {trust: true}); } } else if (revisionItem.type === 'code' || revisionItem.type === 'mermaid') { this.$content.html($("
").text(fullNoteRevision.content));
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/mermaid.js b/src/public/app/widgets/mermaid.js
index d07ce83e0..857eca97e 100644
--- a/src/public/app/widgets/mermaid.js
+++ b/src/public/app/widgets/mermaid.js
@@ -1,7 +1,6 @@
 import libraryLoader from "../services/library_loader.js";
 import NoteContextAwareWidget from "./note_context_aware_widget.js";
 import froca from "../services/froca.js";
-import server from "../services/server.js";
 
 const TPL = `