File Manager

Path: /usr/local/sitepad/editor/site-data/plugins/documentor/assets/admin/js/

Viewing File: script.min.js

/**
 * Documentor Admin — Vue.js Application
 *
 * Manages the WordPress admin UI for the Documentor plugin.
 * Handles creating, cloning, removing, sorting, and exporting
 * documentation posts (docs, sections, articles).
 *
 * Dependencies:
 *   - jQuery (window.jQuery)
 *   - Vue.js (global)
 *   - SweetAlert (swal) — optional, used for confirmation dialogs
 *   - wp.ajax — WordPress AJAX helper
 *   - ajaxurl — WordPress global AJAX endpoint
 *   - window.documentor_admin_vars — localised plugin data
 */

(function() {

	var $ = window.jQuery;
	var admin_vars = window.documentor_admin_vars;

	// Shorthand for the localised i18n strings (translations/labels).
	var __ = admin_vars.__;

	// Disable SweetAlert's built-in open/close animation if the library is loaded.
	if(typeof swal !== 'undefined') {
		swal.setDefaults({ animation: false });
	}

	/**
	 * Organises a flat list of docs into a category-keyed object.
	 *
	 * Each category entry has the shape:
	 *   { name: string, docs: Array }
	 *
	 * A default "uncategorised" bucket with key 0 is always created first,
	 * so docs without a cat_id fall back to it.
	 *
	 * @param  {Array}  docs  Flat array of doc objects returned by the server.
	 * @return {Object}       Category-keyed map: { [cat_id]: { name, docs[] } }
	 */
	function categorize_docs(docs) {
		// Clone the array so we don't mutate the original.
		var docs_copy = Object.assign([], docs);

		// Start with a default uncategorised bucket.
		var categorized = {
			0: { name: '', docs: [] }
		};

		docs_copy.forEach(function(doc) {
			var cat_id = doc.post.cat_id;

			// Create the category bucket if it doesn't exist yet.
			if(!categorized['' + cat_id]) {
				categorized['' + cat_id] = {
					name: doc.post.cat_name,
					docs: []
				};
			}

			categorized[cat_id].docs.push(doc);
		});

		return categorized;
	}

	// ---------------------------------------------------------------------------
	// Custom Vue directive: v-sortable
	// Wraps jQuery UI Sortable on the bound element (a <ul>).
	// After a drag-and-drop reorder, it reads the new order from the DOM
	// and persists it via an AJAX call.
	// ---------------------------------------------------------------------------
	Vue.directive('sortable', {
		bind: function(el) {
			var $el = $(el);

			$el.sortable({
				// Fired when the user drops an item.
				stop: function(event, ui) {
					var ids = [];

					// Collect child item IDs in their new visual order.
					$(ui.item.closest('ul')).children('li').each(function(index, li) {
						ids.push($(li).data('id'));
					});

					// Persist the new sort order.
					wp.ajax.post({
						action:   'documentor_sortable_docs',
						ids:      ids,
						_wpnonce: admin_vars.nonce
					});
				},

				cursor: 'move'
			});

			// Lock the list height when dragging starts so items don't collapse.
			$el.on('mousedown', function() {
				$(this).css('min-height', $(this).height());
			});

			// Release the locked height after the drag ends.
			$el.on('mouseup', function() {
				$(this).css('min-height', '');
			});
		}
	});

	// ---------------------------------------------------------------------------
	// Main Vue instance
	// ---------------------------------------------------------------------------
	new Vue({
		el: '#documentor-app',

		data: {
			editurl:      '',       // Base URL for editing a doc in wp-admin.
			viewurl:      '',       // Base URL for viewing a doc on the front end.
			docs:         [],       // Flat list of all docs (used for mutation).
			categorized:   [],       // Category-bucketed docs (used for display).
			activeHubId:  'default',
			hasHubFilters: false,
			hubSlugToId:  {},
			hubIdToSlug:  {},
			hubData: {
				catHubMap: {},
				docHubMap: {},
				docCatMap: {}
			}
		},

		mounted: function() {
			var vm   = this;
			var $app = $(vm.$el);

			vm.editurl = admin_vars.editurl;
			vm.viewurl = admin_vars.viewurl;
			vm.setupHubFilters();

			// Fetch all docs from the server on page load.
			$.get(
				ajaxurl,
				{
					action:   'documentor_admin_get_docs',
					_wpnonce: admin_vars.nonce
				},
				function(response) {
					var docs = response && response.data ? response.data : [];

					// Reveal the list and remove the loading spinner.
					$app.find('.documentor').removeClass('not-loaded').addClass('loaded');
					$app.find('.spinner').remove();
					$app.find('.no-documentor').removeClass('not-loaded');

					// Populate reactive data.
					vm.docs        = Object.assign([], docs);
					vm.categorized = categorize_docs(docs);

				}
			);
		},

		computed: {
			filteredCategorized: function() {
				var docs = this.docs || [];
				var vm = this;

				if(!this.hasHubFilters) {
					return categorize_docs(docs);
				}
				
				console.log(categorize_docs(docs.filter(function(doc) {
					return vm.docMatchesActiveHub(doc);
				})));

				return categorize_docs(docs.filter(function(doc) {
					return vm.docMatchesActiveHub(doc);
				}));
			}
		},

		methods: {
			setupHubFilters: function() {
				var vm = this;
				var localizedHubData = window.doucumentorHubData || {};
				var $tabs = $(vm.$el).find('.hub-filter-btn > a');

				vm.hubData.catHubMap = localizedHubData.catHubMap || {};
				vm.hubData.docHubMap = localizedHubData.docHubMap || {};
				vm.hubData.docCatMap = localizedHubData.docCatMap || {};
				vm.hasHubFilters = $tabs.length > 0;

				if(!vm.hasHubFilters) {
					return;
				}

				$tabs.each(function() {
					var $tab = $(this);
					var hubId = String($tab.attr('data-hub-id') || '');
					var hubSlug = String($tab.attr('data-hub-slug') || '');

					if(hubId) {
						vm.hubIdToSlug[hubId] = hubSlug;
					}
					if(hubSlug) {
						vm.hubSlugToId[hubSlug] = hubId;
					}
				});

				// Treat "#default" exactly like no hash to keep a single default URL state.
				var hashSlug = window.location.hash.replace('#', '');
				if(hashSlug === 'default') {
					hashSlug = '';
					history.replaceState(null, null, window.location.pathname + window.location.search);
				}

				var initialHubId = hashSlug && vm.hubSlugToId[hashSlug]
					? vm.hubSlugToId[hashSlug]
					: 'default';

				vm.setActiveHub(initialHubId, true);

				$(vm.$el).on('click', '.hub-filter-btn > a', function(event) {
					event.preventDefault();
					vm.setActiveHub($(this).attr('data-hub-id'));
				});
			},

			setActiveHub: function(hubId, isInitial) {
				var selectedHubId = String(hubId || 'default');
				var slug;

				this.activeHubId = selectedHubId;
				this.applyActiveHubTabState();
				slug = this.hubIdToSlug[selectedHubId] || '';

				if(slug && slug !== 'default') {
					history.replaceState(null, null, '#' + slug);
				} else{
					history.replaceState(null, null, window.location.pathname + window.location.search);
				}

				if(!isInitial) {
					$(document).trigger('documentor:hub-tab-changed', [selectedHubId, slug]);
				}
			},

			applyActiveHubTabState: function() {
				var selectedHubId = String(this.activeHubId || 'default');
				var $tabs = $(this.$el).find('.hub-filter-btn > a');

				$tabs.removeClass('active');
				$tabs.filter('[data-hub-id="' + selectedHubId + '"]').addClass('active');
			},

			getDocHubIds: function(doc) {
				var docId = doc && doc.post ? String(doc.post.id) : '';
				var docHubMap = this.hubData.docHubMap || {};
				var hubs = docHubMap[docId] || docHubMap[parseInt(docId, 10)] || [];

				if(!Array.isArray(hubs)) {
					hubs = hubs ? [hubs] : [];
				}

				// Some responses expose hub via doc_hub slug only; support that shape too.
				if(!hubs.length && doc && doc.doc_hub) {
					hubs = [doc.doc_hub];
				}

				return hubs.map(function(hubId) {
					return String(hubId);
				});
			},

			getDocCategoryIds: function(doc) {
				var docId = doc && doc.post ? String(doc.post.id) : '';
				var docCatMap = this.hubData.docCatMap || {};
				var categories = docCatMap[docId] || docCatMap[parseInt(docId, 10)] || [];

				if(!Array.isArray(categories)) {
					categories = categories ? [categories] : [];
				}

				if(!categories.length && doc && doc.post && doc.post.cat_id) {
					categories = [doc.post.cat_id];
				}

				return categories.map(function(catId) {
					return String(catId);
				});
			},

			hubMatchesSelected: function(hubValue, selectedHubId) {
				var value = String(hubValue || '');
				var selected = String(selectedHubId || 'default');
				var selectedSlug = this.hubIdToSlug[selected] || '';
				var normalizedValueId = this.hubSlugToId[value] || value;

				// Match either by hub ID or by slug so mixed payload formats still work.
				if(normalizedValueId === selected) {
					return true;
				}

				if(selectedSlug && value === selectedSlug) {
					return true;
				}

				return false;
			},

			categoryMatchesHub: function(catId, selectedHubId) {
				var catHubMap = this.hubData.catHubMap || {};
				var hubsForCat = catHubMap[String(catId)] || catHubMap[parseInt(catId, 10)] || [];

				if(!Array.isArray(hubsForCat)) {
					hubsForCat = hubsForCat ? [hubsForCat] : [];
				}

				for(var i = 0; i < hubsForCat.length; i++) {
					if(this.hubMatchesSelected(hubsForCat[i], selectedHubId)) {
						return true;
					}
				}

				return false;
			},

			docMatchesActiveHub: function(doc) {
				if(!this.hasHubFilters) {
					return true;
				}

				var selectedHubId = String(this.activeHubId || 'default');
				var docHubs = this.getDocHubIds(doc);
				var docCategories = this.getDocCategoryIds(doc);
				var docCategoriesMatchesHub = false;

				if(selectedHubId === 'default') {
					return docHubs.length === 0;
				}

				if(docHubs.length > 0) {
					for(var i = 0; i < docHubs.length; i++) {
						if(this.hubMatchesSelected(docHubs[i], selectedHubId)) {
							return true;
						}
					}

					return false;
				}

				for(var j = 0; j < docCategories.length; j++) {
					if(this.categoryMatchesHub(docCategories[j], selectedHubId)) {
						docCategoriesMatchesHub = true;
						break;
					}
				}

				return docCategoriesMatchesHub;
			},

			syncNewDocHubMap: function(doc) {
				var activeHubId = String(this.activeHubId || 'default');

				if(
					!doc ||
					!doc.post ||
					!doc.post.id ||
					activeHubId === 'default'
				) {
					return;
				}

				this.hubData.docHubMap[String(doc.post.id)] = [activeHubId];
			},

			/**
			 * Generic error handler — shows a SweetAlert error dialog
			 * and logs the raw error to the console.
			 *
			 * @param {Object|string} error  jQuery AJAX error object or a plain message.
			 */
			onError: function(error) {
				swal({
					title:          'Error!',
					text:           error.statusText || error.responseText || error,
					type:           'error',
					closeOnConfirm: true,
					customClass:    'documentor-swal'
				});
				console.log(error);
			},

			/**
			 * Prompts for a title and creates a new top-level doc.
			 * The new doc is prepended to vm.docs and the categorized map is rebuilt.
			 */
			addDoc: function() {
				var vm = this;
				vm.docs = vm.docs || [];

				swal(
					{
						title:               __.enter_doc_title,
						type:                'input',
						showCancelButton:    true,
						closeOnConfirm:      false,
						showLoaderOnConfirm: true,
						inputPlaceholder:    __.enter_doc_title,
						customClass:         'documentor-swal'
					},
					function(title) {
						// User cancelled.
						if(title === false) {
							swal.close();
							return false;
						}

						wp.ajax.send({
							data: {
								action:   'documentor_create_doc',
								title:    title,
								parent:   0,
								_wpnonce: admin_vars.nonce
							},
							success: function(new_doc) {
								vm.docs.unshift(new_doc);
								vm.syncNewDocHubMap(new_doc);
								vm.categorized = categorize_docs(vm.docs);
								swal.close();
							},
							error: vm.on_error
						});
					}
				);
			},

			/**
			 * Prompts for a new title and clones an existing doc.
			 * The pre-filled title is built from the clone i18n string.
			 *
			 * @param {Object} doc  The doc object to clone.
			 */
			cloneDoc: function(doc) {
				var vm = this;
				vm.docs = vm.docs || [];

				swal(
					{
						title:               __.enter_doc_title,
						type:                'input',
						showCancelButton:    true,
						closeOnConfirm:      false,
						showLoaderOnConfirm: true,
						inputPlaceholder:    __.enter_doc_title,
						// Pre-fill with e.g. "Clone of My Doc".
						inputValue:          __.clone_default_title.replace('%s', doc.post.title),
						customClass:         'documentor-swal'
					},
					function(title) {
						if(title === false) {
							swal.close();
							return false;
						}

						wp.ajax.send({
							data: {
								action:     'documentor_clone_doc',
								title:      title,
								clone_from: doc.post.id,
								_wpnonce:   admin_vars.nonce
							},
							success: function(new_doc) {
								vm.docs.unshift(new_doc);
								vm.syncNewDocHubMap(new_doc);
								vm.categorized = categorize_docs(vm.docs);
								swal.close();
							},
							error: vm.onError
						});
					}
				);
			},

			/**
			 * Shows a confirmation dialog before removing a top-level doc.
			 *
			 * @param {number} index  Index of the doc within its parent array.
			 * @param {Array}  list   The array that contains the doc.
			 */
			removeDoc: function(index, list) {
				var vm = this;

				swal(
					{
						title:             __.remove_doc_title,
						text:              __.remove_doc_text,
						type:              'warning',
						showCancelButton:  true,
						confirmButtonText: __.remove_doc_button_yes,
						closeOnConfirm:    false,
						showLoaderOnConfirm: true,
						customClass:       'documentor-swal'
					},
					function() {
						vm.removePost(index, list);
					}
				);
			},

			/**
			 * Exports a doc via a server-sent events (SSE) stream.
			 * Displays an animated progress bar while the export runs,
			 * then offers a download link on completion.
			 *
			 * @param {Object} doc  The doc object to export.
			 */
			exportDoc: function(doc) {
				var vm = this;

				// First dialog: confirm the export.
				swal(
					{
						html:              true,
						title:             __.clone_default_title.replace(
							'%s',
							'<strong>' + doc.post.title + '</strong>'
						),
						text:              __.export_doc_text,
						type:              'info',
						showCancelButton:  true,
						confirmButtonText: __.export_doc_button_yes,
						closeOnConfirm:    false,
						customClass:       'documentor-swal'
					},
					function() {
						// Second dialog: show progress.
						swal(
							{
								html:             true,
								title:            __.exporting_doc_title,
								text:             '<div class="documentor-export-response">'
								                  + __.exporting_doc_text
								                  + '</div>'
								                  + '<div class="documentor-export-progress">'
								                  + '<div class="documentor-export-progress-bar"></div>'
								                  + '</div>',
								type:             'info',
								showCancelButton: true,
								showConfirmButton: false,
								closeOnCancel:    false,
								customClass:      'documentor-swal'
							},
							function() {
								// User clicked Cancel: close the SSE stream and the dialog.
								event_source.close();
								swal.close();
							}
						);

						var $response_el    = $('.documentor-export-response');
						var $progress_bar   = $('.documentor-export-progress .documentor-export-progress-bar');
						var message_count   = 0;

						// Open a server-sent events stream for the export action.
						var event_source = new window.EventSource(
							ajaxurl + '?action=documentor_export_doc&doc_id=' + doc.post.id
						);

						event_source.onmessage = function(event) {
							var data = JSON.parse(event.data);
							console.log(data);

							switch(data.action) {
								// Incremental progress update.
								case 'message':
									message_count++;
									$response_el.text(data.message);
									// Update the progress bar width as a percentage.
									$progress_bar.css(
										'width',
										(100 * message_count / data.max_delta) + '%'
									);
									break;

								// Export finished — show a download link.
								case 'complete':
									event_source.close();

									swal(
										{
											html:             true,
											title:            __.exported_doc_title,
											text:             '<a class="button button-primary button-hero" href="'
											                  + data.message + '">'
											                  + __.exported_doc_download
											                  + '</a>',
											type:             'success',
											showCancelButton: true,
											showConfirmButton: false,
											closeOnCancel:    true,
											cancelButtonText: __.exported_doc_cancel,
											customClass:      'documentor-swal'
										},
										function() {
											swal.close();
										}
									);
									break;
							}
						};

						event_source.onerror = function() {
							vm.onError(this);
							event_source.close();
						};
					}
				);
			},

			/**
			 * Prompts for a title and creates a new section (child of a doc).
			 *
			 * @param {Object} doc  The parent doc object.
			 */
			addSection: function(doc) {
				var vm = this;

				swal(
					{
						title:               __.enter_section_title,
						type:                'input',
						showCancelButton:    true,
						closeOnConfirm:      false,
						showLoaderOnConfirm: true,
						inputPlaceholder:    __.enter_section_title,
						customClass:         'documentor-swal'
					},
					function(title) {
						if(title === false) {
							swal.close();
							return false;
						}

						// Trim whitespace and skip empty titles.
						title = title.trim();
						if(!title) return;

						wp.ajax.send({
							data: {
								action:   'documentor_create_doc',
								title:    title,
								parent:   doc.post.id,
								order:    doc.child.length,
								_wpnonce: admin_vars.nonce
							},
							success: function(new_section) {
								doc.child.push(new_section);
								swal.close();
							},
							error: vm.onError
						});
					}
				);
			},

			/**
			 * Shows a confirmation dialog before removing a section.
			 *
			 * @param {number} index  Index of the section within its parent array.
			 * @param {Array}  list   The array that contains the section.
			 */
			removeSection: function(index, list) {
				var vm = this;

				swal(
					{
						title:               __.remove_section_title,
						text:                __.remove_section_text,
						type:                'warning',
						showCancelButton:    true,
						confirmButtonText:   __.remove_section_button_yes,
						closeOnConfirm:      false,
						showLoaderOnConfirm: true,
						customClass:         'documentor-swal'
					},
					function() {
						vm.removePost(index, list);
					}
				);
			},

			/**
			 * Prompts for a title, creates a new article (child of a section),
			 * and expands the section's article list if it was collapsed.
			 *
			 * @param {Object} section  The parent section object.
			 * @param {Event}  event    The click event (used to find the section list element).
			 */
			addArticle: function(section, event) {
				var original_event = event;
				var vm             = this;

				swal(
					{
						title:               __.enter_doc_title,
						type:                'input',
						showCancelButton:    true,
						closeOnConfirm:      false,
						showLoaderOnConfirm: true,
						inputPlaceholder:    __.enter_doc_title,
						customClass:         'documentor-swal'
					},
					function(title) {
						if(title === false) {
							swal.close();
							return false;
						}

						wp.ajax.send({
							data: {
								action:   'documentor_create_doc',
								title:    title,
								parent:   section.post.id,
								status:   'draft',
								order:    section.child.length,
								_wpnonce: admin_vars.nonce
							},
							success: function(new_article) {
								section.child.push(new_article);

								// Auto-expand the section's article list if it's collapsed.
								var $articles_list = $(original_event.target)
									.closest('.section-title')
									.next();

								if($articles_list.hasClass('collapsed')) {
									$articles_list.removeClass('collapsed');
								}

								swal.close();
							},
							error: vm.onError
						});
					}
				);
			},

			/**
			 * Shows a confirmation dialog before removing an article.
			 *
			 * @param {number} index  Index of the article within its parent array.
			 * @param {Array}  list   The array that contains the article.
			 */
			removeArticle: function(index, list) {
				var vm = this;

				swal(
					{
						title:               __.remove_article_title,
						text:                __.remove_article_text,
						type:                'warning',
						showCancelButton:    true,
						confirmButtonText:   __.remove_article_button_yes,
						closeOnConfirm:      false,
						showLoaderOnConfirm: true,
						customClass:         'documentor-swal'
					},
					function() {
						vm.removePost(index, list);
					}
				);
			},

			/**
			 * Sends a delete request to the server and removes the post from
			 * vm.docs recursively (handles nested sections/articles).
			 * Also rebuilds the categorized map after removal.
			 *
			 * @param {number} index  Index of the item to remove within `list`.
			 * @param {Array}  list   The array that contains the item to remove.
			 */
			removePost: function(index, list) {
				var vm      = this;
				var post_id = list[index].post.id;

				wp.ajax.send({
					data: {
						action:   'documentor_remove_doc',
						id:       post_id,
						_wpnonce: admin_vars.nonce
					},
					success: function() {
						// Recursively strip the deleted post from the tree.
						vm.docs        = remove_from_tree(vm.docs, post_id);
						vm.categorized = categorize_docs(vm.docs);
						swal.close();
					},
					error: vm.onError
				});
			},

			/**
			 * Toggles the collapsed state of a section's article list.
			 *
			 * @param {Event} event  The click event fired on the toggle control.
			 */
			toggleCollapse: function(event) {
				$(event.target).siblings('ul.articles').toggleClass('collapsed');
			}
		}
	});

	// ---------------------------------------------------------------------------
	// Private helper: recursive tree removal
	// ---------------------------------------------------------------------------

	/**
	 * Walks a nested docs tree and removes the node whose post.id === target_id.
	 * Operates recursively on each node's `child` array.
	 *
	 * @param  {Array}  docs       Array of doc nodes (may contain nested children).
	 * @param  {number} target_id  The post ID to remove.
	 * @return {Array}             The mutated array (same reference).
	 */
	function remove_from_tree(docs, target_id) {
		for(var i = 0; i < docs.length; i++) {
			if(docs[i].post.id === target_id) {
				// Found it at this level — splice it out.
				docs.splice(i, 1);
			} else if(docs[i].child && docs[i].child.length) {
				// Not here — recurse into children.
				docs[i].child = remove_from_tree(docs[i].child, target_id);
			}
		}

		return docs;
	}

})();