File Manager
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;
}
})();