mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	introduced bulk action groups
This commit is contained in:
		| @@ -14,12 +14,21 @@ $dialog.on('click', '[data-action-add]', async event => { | |||||||
|     await refresh(); |     await refresh(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| for (const action of bulkActionService.ACTION_CLASSES) { | for (const actionGroup of bulkActionService.ACTION_GROUPS) { | ||||||
|     $availableActionList.append( |     const $actionGroupList = $("<td>"); | ||||||
|  |     const $actionGroup = $("<tr>") | ||||||
|  |         .append($("<td>").text(actionGroup.title + ": ")) | ||||||
|  |         .append($actionGroupList); | ||||||
|  |  | ||||||
|  |     for (const action of actionGroup.actions) { | ||||||
|  |         $actionGroupList.append( | ||||||
|             $('<button class="btn btn-sm">') |             $('<button class="btn btn-sm">') | ||||||
|                 .attr('data-action-add', action.actionName) |                 .attr('data-action-add', action.actionName) | ||||||
|                 .text(action.actionTitle) |                 .text(action.actionTitle) | ||||||
|         ); |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     $availableActionList.append($actionGroup); | ||||||
| } | } | ||||||
|  |  | ||||||
| async function refresh() { | async function refresh() { | ||||||
|   | |||||||
| @@ -13,6 +13,25 @@ import ExecuteScriptBulkAction from "../widgets/bulk_actions/execute_script.js"; | |||||||
| import AddLabelBulkAction from "../widgets/bulk_actions/label/add_label.js"; | import AddLabelBulkAction from "../widgets/bulk_actions/label/add_label.js"; | ||||||
| import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation.js"; | import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation.js"; | ||||||
|  |  | ||||||
|  | const ACTION_GROUPS = [ | ||||||
|  |     { | ||||||
|  |         title: 'Labels', | ||||||
|  |         actions: [AddLabelBulkAction, UpdateLabelValueBulkAction, RenameLabelBulkAction, DeleteLabelBulkAction] | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         title: 'Relations', | ||||||
|  |         actions: [AddRelationBulkAction, UpdateRelationTargetBulkAction, RenameRelationBulkAction, DeleteRelationBulkAction] | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         title: 'Notes', | ||||||
|  |         actions: [DeleteNoteBulkAction, DeleteNoteRevisionsBulkAction, MoveNoteBulkAction], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         title: 'Other', | ||||||
|  |         actions: [ExecuteScriptBulkAction] | ||||||
|  |     } | ||||||
|  | ]; | ||||||
|  |  | ||||||
| const ACTION_CLASSES = [ | const ACTION_CLASSES = [ | ||||||
|     MoveNoteBulkAction, |     MoveNoteBulkAction, | ||||||
|     DeleteNoteBulkAction, |     DeleteNoteBulkAction, | ||||||
| @@ -68,5 +87,6 @@ function parseActions(note) { | |||||||
| export default { | export default { | ||||||
|     addAction, |     addAction, | ||||||
|     parseActions, |     parseActions, | ||||||
|     ACTION_CLASSES |     ACTION_CLASSES, | ||||||
|  |     ACTION_GROUPS | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ const TPL = ` | |||||||
| <tr> | <tr> | ||||||
|     <td colspan="2"> |     <td colspan="2"> | ||||||
|         <div style="display: flex; align-items: center"> |         <div style="display: flex; align-items: center"> | ||||||
|             <div style="margin-right: 10px;" class="text-nowrap">Set label</div>  |             <div style="margin-right: 10px;" class="text-nowrap">Add label</div>  | ||||||
|              |              | ||||||
|             <input type="text"  |             <input type="text"  | ||||||
|                 class="form-control label-name"  |                 class="form-control label-name"  | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ const TPL = ` | |||||||
| <tr> | <tr> | ||||||
|     <td colspan="2"> |     <td colspan="2"> | ||||||
|         <div style="display: flex; align-items: center"> |         <div style="display: flex; align-items: center"> | ||||||
|             <div style="margin-right: 10px;" class="text-nowrap">Set relation</div>  |             <div style="margin-right: 10px;" class="text-nowrap">Add relation</div>  | ||||||
|  |  | ||||||
|             <input type="text"  |             <input type="text"  | ||||||
|                 class="form-control relation-name"  |                 class="form-control relation-name"  | ||||||
|   | |||||||
| @@ -64,6 +64,10 @@ const TPL = ` | |||||||
|     .add-search-option button { |     .add-search-option button { | ||||||
|         margin-top: 5px; /* to give some spacing when buttons overflow on the next line */ |         margin-top: 5px; /* to give some spacing when buttons overflow on the next line */ | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     .dropdown-header { | ||||||
|  |         background-color: var(--accented-background-color); | ||||||
|  |     } | ||||||
|     </style> |     </style> | ||||||
|  |  | ||||||
|     <div class="search-settings"> |     <div class="search-settings"> | ||||||
| @@ -183,13 +187,17 @@ export default class SearchDefinitionWidget extends NoteContextAwareWidget { | |||||||
|         this.$component = this.$widget.find('.search-definition-widget'); |         this.$component = this.$widget.find('.search-definition-widget'); | ||||||
|         this.$actionList = this.$widget.find('.action-list'); |         this.$actionList = this.$widget.find('.action-list'); | ||||||
|  |  | ||||||
|         for (const action of bulkActionService.ACTION_CLASSES) { |         for (const actionGroup of bulkActionService.ACTION_GROUPS) { | ||||||
|  |             this.$actionList.append($('<h6 class="dropdown-header">').append(actionGroup.title)); | ||||||
|  |  | ||||||
|  |             for (const action of actionGroup.actions) { | ||||||
|                 this.$actionList.append( |                 this.$actionList.append( | ||||||
|                     $('<a class="dropdown-item" href="#">') |                     $('<a class="dropdown-item" href="#">') | ||||||
|                         .attr('data-action-add', action.actionName) |                         .attr('data-action-add', action.actionName) | ||||||
|                         .text(action.actionTitle) |                         .text(action.actionTitle) | ||||||
|                 ); |                 ); | ||||||
|             } |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         this.$widget.on('click', '[data-search-option-add]', async event => { |         this.$widget.on('click', '[data-search-option-add]', async event => { | ||||||
|             const searchOptionName = $(event.target).attr('data-search-option-add'); |             const searchOptionName = $(event.target).attr('data-search-option-add'); | ||||||
|   | |||||||
| @@ -5,9 +5,7 @@ const SearchContext = require('../../services/search/search_context'); | |||||||
| const log = require('../../services/log'); | const log = require('../../services/log'); | ||||||
| const scriptService = require('../../services/script'); | const scriptService = require('../../services/script'); | ||||||
| const searchService = require('../../services/search/services/search'); | const searchService = require('../../services/search/services/search'); | ||||||
| const noteRevisionService = require("../../services/note_revisions"); | const bulkActionService = require("../../services/bulk_actions"); | ||||||
| const branchService = require("../../services/branches"); |  | ||||||
| const cloningService = require("../../services/cloning"); |  | ||||||
| const {formatAttrForSearch} = require("../../services/attribute_formatter"); | const {formatAttrForSearch} = require("../../services/attribute_formatter"); | ||||||
|  |  | ||||||
| async function searchFromNoteInt(note) { | async function searchFromNoteInt(note) { | ||||||
| @@ -59,108 +57,6 @@ async function searchFromNote(req) { | |||||||
|     return await searchFromNoteInt(note); |     return await searchFromNoteInt(note); | ||||||
| } | } | ||||||
|  |  | ||||||
| const ACTION_HANDLERS = { |  | ||||||
|     deleteNote: (action, note) => { |  | ||||||
|         note.markAsDeleted(); |  | ||||||
|     }, |  | ||||||
|     deleteNoteRevisions: (action, note) => { |  | ||||||
|         noteRevisionService.eraseNoteRevisions(note.getNoteRevisions().map(rev => rev.noteRevisionId)); |  | ||||||
|     }, |  | ||||||
|     deleteLabel: (action, note) => { |  | ||||||
|         for (const label of note.getOwnedLabels(action.labelName)) { |  | ||||||
|             label.markAsDeleted(); |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     deleteRelation: (action, note) => { |  | ||||||
|         for (const relation of note.getOwnedRelations(action.relationName)) { |  | ||||||
|             relation.markAsDeleted(); |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     renameLabel: (action, note) => { |  | ||||||
|         for (const label of note.getOwnedLabels(action.oldLabelName)) { |  | ||||||
|             // attribute name is immutable, renaming means delete old + create new |  | ||||||
|             const newLabel = label.createClone('label', action.newLabelName, label.value); |  | ||||||
|  |  | ||||||
|             newLabel.save(); |  | ||||||
|             label.markAsDeleted(); |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     renameRelation: (action, note) => { |  | ||||||
|         for (const relation of note.getOwnedRelations(action.oldRelationName)) { |  | ||||||
|             // attribute name is immutable, renaming means delete old + create new |  | ||||||
|             const newRelation = relation.createClone('relation', action.newRelationName, relation.value); |  | ||||||
|  |  | ||||||
|             newRelation.save(); |  | ||||||
|             relation.markAsDeleted(); |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     updateLabelValue: (action, note) => { |  | ||||||
|         for (const label of note.getOwnedLabels(action.labelName)) { |  | ||||||
|             label.value = action.labelValue; |  | ||||||
|             label.save(); |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     updateRelationTarget: (action, note) => { |  | ||||||
|         for (const relation of note.getOwnedLabels(action.relationName)) { |  | ||||||
|             relation.value = action.targetNoteId; |  | ||||||
|             relation.save(); |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     moveNote: (action, note) => { |  | ||||||
|         const targetParentNote = becca.getNote(action.targetParentNoteId); |  | ||||||
|  |  | ||||||
|         if (!targetParentNote) { |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         let res; |  | ||||||
|  |  | ||||||
|         if (note.getParentBranches().length > 1) { |  | ||||||
|             res = cloningService.cloneNoteToNote(note.noteId, action.targetParentNoteId); |  | ||||||
|         } |  | ||||||
|         else { |  | ||||||
|             res = branchService.moveBranchToNote(note.getParentBranches()[0], action.targetParentNoteId); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (!res.success) { |  | ||||||
|             log.info(`Moving/cloning note ${note.noteId} to ${action.targetParentNoteId} failed with error ${JSON.stringify(res)}`); |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     executeScript: (action, note) => { |  | ||||||
|         if (!action.script || !action.script.trim()) { |  | ||||||
|             log.info("Ignoring executeScript since the script is empty.") |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const scriptFunc = new Function("note", action.script); |  | ||||||
|         scriptFunc(note); |  | ||||||
|  |  | ||||||
|         note.save(); |  | ||||||
|     } |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| function getActions(note) { |  | ||||||
|     return note.getLabels('action') |  | ||||||
|         .map(actionLabel => { |  | ||||||
|             let action; |  | ||||||
|  |  | ||||||
|             try { |  | ||||||
|                 action = JSON.parse(actionLabel.value); |  | ||||||
|             } catch (e) { |  | ||||||
|                 log.error(`Cannot parse '${actionLabel.value}' into search action, skipping.`); |  | ||||||
|                 return null; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             if (!(action.name in ACTION_HANDLERS)) { |  | ||||||
|                 log.error(`Cannot find '${action.name}' search action handler, skipping.`); |  | ||||||
|                 return null; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             return action; |  | ||||||
|         }) |  | ||||||
|         .filter(a => !!a); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function searchAndExecute(req) { | async function searchAndExecute(req) { | ||||||
|     const note = becca.getNote(req.params.noteId); |     const note = becca.getNote(req.params.noteId); | ||||||
|  |  | ||||||
| @@ -179,26 +75,7 @@ async function searchAndExecute(req) { | |||||||
|  |  | ||||||
|     const searchResultNoteIds = await searchFromNoteInt(note); |     const searchResultNoteIds = await searchFromNoteInt(note); | ||||||
|  |  | ||||||
|     const actions = getActions(note); |     bulkActionService.executeActions(note, searchResultNoteIds); | ||||||
|  |  | ||||||
|     for (const resultNoteId of searchResultNoteIds) { |  | ||||||
|         const resultNote = becca.getNote(resultNoteId); |  | ||||||
|  |  | ||||||
|         if (!resultNote || resultNote.isDeleted) { |  | ||||||
|             continue; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         for (const action of actions) { |  | ||||||
|             try { |  | ||||||
|                 log.info(`Applying action handler to note ${resultNote.noteId}: ${JSON.stringify(action)}`); |  | ||||||
|  |  | ||||||
|                 ACTION_HANDLERS[action.name](action, resultNote); |  | ||||||
|             } |  | ||||||
|             catch (e) { |  | ||||||
|                 log.error(`ExecuteScript search action failed with ${e.message}`); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function searchFromRelation(note, relationName) { | function searchFromRelation(note, relationName) { | ||||||
|   | |||||||
							
								
								
									
										139
									
								
								src/services/bulk_actions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/services/bulk_actions.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | |||||||
|  | const log = require("./log.js"); | ||||||
|  | const noteRevisionService = require("./note_revisions.js"); | ||||||
|  | const becca = require("../becca/becca.js"); | ||||||
|  | const cloningService = require("./cloning.js"); | ||||||
|  | const branchService = require("./branches.js"); | ||||||
|  |  | ||||||
|  | const ACTION_HANDLERS = { | ||||||
|  |     addLabel: (action, note) => { | ||||||
|  |         note.addLabel(action.labelName, action.labelValue); | ||||||
|  |     }, | ||||||
|  |     addRelation: (action, note) => { | ||||||
|  |         note.addRelation(action.relationName, action.targetNoteId); | ||||||
|  |     }, | ||||||
|  |     deleteNote: (action, note) => { | ||||||
|  |         note.markAsDeleted(); | ||||||
|  |     }, | ||||||
|  |     deleteNoteRevisions: (action, note) => { | ||||||
|  |         noteRevisionService.eraseNoteRevisions(note.getNoteRevisions().map(rev => rev.noteRevisionId)); | ||||||
|  |     }, | ||||||
|  |     deleteLabel: (action, note) => { | ||||||
|  |         for (const label of note.getOwnedLabels(action.labelName)) { | ||||||
|  |             label.markAsDeleted(); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     deleteRelation: (action, note) => { | ||||||
|  |         for (const relation of note.getOwnedRelations(action.relationName)) { | ||||||
|  |             relation.markAsDeleted(); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     renameLabel: (action, note) => { | ||||||
|  |         for (const label of note.getOwnedLabels(action.oldLabelName)) { | ||||||
|  |             // attribute name is immutable, renaming means delete old + create new | ||||||
|  |             const newLabel = label.createClone('label', action.newLabelName, label.value); | ||||||
|  |  | ||||||
|  |             newLabel.save(); | ||||||
|  |             label.markAsDeleted(); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     renameRelation: (action, note) => { | ||||||
|  |         for (const relation of note.getOwnedRelations(action.oldRelationName)) { | ||||||
|  |             // attribute name is immutable, renaming means delete old + create new | ||||||
|  |             const newRelation = relation.createClone('relation', action.newRelationName, relation.value); | ||||||
|  |  | ||||||
|  |             newRelation.save(); | ||||||
|  |             relation.markAsDeleted(); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     updateLabelValue: (action, note) => { | ||||||
|  |         for (const label of note.getOwnedLabels(action.labelName)) { | ||||||
|  |             label.value = action.labelValue; | ||||||
|  |             label.save(); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     updateRelationTarget: (action, note) => { | ||||||
|  |         for (const relation of note.getOwnedLabels(action.relationName)) { | ||||||
|  |             relation.value = action.targetNoteId; | ||||||
|  |             relation.save(); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     moveNote: (action, note) => { | ||||||
|  |         const targetParentNote = becca.getNote(action.targetParentNoteId); | ||||||
|  |  | ||||||
|  |         if (!targetParentNote) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let res; | ||||||
|  |  | ||||||
|  |         if (note.getParentBranches().length > 1) { | ||||||
|  |             res = cloningService.cloneNoteToNote(note.noteId, action.targetParentNoteId); | ||||||
|  |         } | ||||||
|  |         else { | ||||||
|  |             res = branchService.moveBranchToNote(note.getParentBranches()[0], action.targetParentNoteId); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!res.success) { | ||||||
|  |             log.info(`Moving/cloning note ${note.noteId} to ${action.targetParentNoteId} failed with error ${JSON.stringify(res)}`); | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     executeScript: (action, note) => { | ||||||
|  |         if (!action.script || !action.script.trim()) { | ||||||
|  |             log.info("Ignoring executeScript since the script is empty.") | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const scriptFunc = new Function("note", action.script); | ||||||
|  |         scriptFunc(note); | ||||||
|  |  | ||||||
|  |         note.save(); | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | function getActions(note) { | ||||||
|  |     return note.getLabels('action') | ||||||
|  |         .map(actionLabel => { | ||||||
|  |             let action; | ||||||
|  |  | ||||||
|  |             try { | ||||||
|  |                 action = JSON.parse(actionLabel.value); | ||||||
|  |             } catch (e) { | ||||||
|  |                 log.error(`Cannot parse '${actionLabel.value}' into search action, skipping.`); | ||||||
|  |                 return null; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!(action.name in ACTION_HANDLERS)) { | ||||||
|  |                 log.error(`Cannot find '${action.name}' search action handler, skipping.`); | ||||||
|  |                 return null; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return action; | ||||||
|  |         }) | ||||||
|  |         .filter(a => !!a); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function executeActions(note, searchResultNoteIds) { | ||||||
|  |     const actions = getActions(note); | ||||||
|  |  | ||||||
|  |     for (const resultNoteId of searchResultNoteIds) { | ||||||
|  |         const resultNote = becca.getNote(resultNoteId); | ||||||
|  |  | ||||||
|  |         if (!resultNote || resultNote.isDeleted) { | ||||||
|  |             continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for (const action of actions) { | ||||||
|  |             try { | ||||||
|  |                 log.info(`Applying action handler to note ${resultNote.noteId}: ${JSON.stringify(action)}`); | ||||||
|  |  | ||||||
|  |                 ACTION_HANDLERS[action.name](action, resultNote); | ||||||
|  |             } catch (e) { | ||||||
|  |                 log.error(`ExecuteScript search action failed with ${e.message}`); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = { | ||||||
|  |     executeActions | ||||||
|  | }; | ||||||
| @@ -16,7 +16,6 @@ | |||||||
|                     <span aria-hidden="true">×</span> |                     <span aria-hidden="true">×</span> | ||||||
|                 </button> |                 </button> | ||||||
|             </div> |             </div> | ||||||
|             <form id="clone-to-form"> |  | ||||||
|             <div class="modal-body"> |             <div class="modal-body"> | ||||||
|                 Affected notes: <span id="affected-note-count">0</span> |                 Affected notes: <span id="affected-note-count">0</span> | ||||||
|  |  | ||||||
| @@ -29,14 +28,13 @@ | |||||||
|  |  | ||||||
|                 Available actions: |                 Available actions: | ||||||
|  |  | ||||||
|                     <div id="bulk-available-action-list"></div> |                 <table id="bulk-available-action-list"></table> | ||||||
|  |  | ||||||
|                 <div id="bulk-existing-action-list"></div> |                 <div id="bulk-existing-action-list"></div> | ||||||
|             </div> |             </div> | ||||||
|             <div class="modal-footer"> |             <div class="modal-footer"> | ||||||
|                 <button type="submit" class="btn btn-primary">Execute bulk actions</button> |                 <button type="submit" class="btn btn-primary">Execute bulk actions</button> | ||||||
|             </div> |             </div> | ||||||
|             </form> |  | ||||||
|         </div> |         </div> | ||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user