977 lines
42 KiB
JavaScript
977 lines
42 KiB
JavaScript
/**
|
|
* Registers settings for the Nextcloud integration in the game.
|
|
* This includes settings for server URL, user credentials, and other preferences.
|
|
*/
|
|
function registerSettings() {
|
|
game.settings.register('nextcloud-filepicker', 'url', {
|
|
name: 'Nextcloud Server URL',
|
|
scope: 'world',
|
|
config: true,
|
|
type: String,
|
|
default: ''
|
|
});
|
|
game.settings.register('nextcloud-filepicker', 'userName', {
|
|
name: 'Nextcloud Account User Name',
|
|
scope: 'world',
|
|
config: true,
|
|
type: String,
|
|
default: ''
|
|
});
|
|
game.settings.register('nextcloud-filepicker', 'appPassword', {
|
|
name: 'Nextcloud Account App Password',
|
|
scope: 'world',
|
|
hint: 'Do not use your user password. Creating an app password: Nextcloud Server Website -> Profile Picture -> Personal Settings -> Security -> Devices & Sessions -> "App name" -> Create new password',
|
|
config: true,
|
|
type: String,
|
|
default: ''
|
|
});
|
|
game.settings.register('nextcloud-filepicker', 'subdirectory', {
|
|
name: 'Nextcloud Subdirectory',
|
|
scope: 'world',
|
|
hint: 'Optional. Use this field if you wish to only have access to a subdirectory within your Nextcloud storage.',
|
|
config: true,
|
|
type: String,
|
|
default: ''
|
|
});
|
|
game.settings.register('nextcloud-filepicker', 'skipPublicLinkConfirmation', {
|
|
name: 'Skip Public Link Confirmation',
|
|
hint: 'If enabled, the public link creation confirmation dialog will be skipped.',
|
|
scope: 'client',
|
|
config: true,
|
|
type: Boolean,
|
|
default: false
|
|
});
|
|
game.settings.register("nextcloud-filepicker", "nextcloudFilePaths", {
|
|
name: "Nextcloud Filepaths",
|
|
hint: "This setting is only for the purpose of preserving filepaths for navigation convenience",
|
|
scope: "world",
|
|
config: false,
|
|
type: Object,
|
|
default: {}
|
|
});
|
|
}
|
|
/**
|
|
* Extends the FilePicker to integrate with Nextcloud, allowing file browsing and operations within Nextcloud storage.
|
|
*/
|
|
class NextcloudFilePicker extends FilePicker {
|
|
static thumbnailCache = {};
|
|
/**
|
|
* Constructs an instance of NextcloudFilePicker with specified options.
|
|
* Initializes the file sources for Nextcloud and sets the active source if the URL matches Nextcloud.
|
|
* @param {Object} options - Configuration options for the FilePicker.
|
|
*/
|
|
constructor(options = {}) {
|
|
super(options);
|
|
this.sources.nextcloud = {
|
|
target: "",
|
|
label: "Nextcloud Data",
|
|
icon: "fas fa-cloud"
|
|
}
|
|
if(this.isNextcloudUrl(this.request)) {
|
|
const source="nextcloud"
|
|
let nextcloudFilePaths = getSetting("nextcloudFilePaths");
|
|
const target = nextcloudFilePaths[this.request] || "";
|
|
this.activeSource = source;
|
|
this.sources[source].target = target;
|
|
}
|
|
}
|
|
/**
|
|
* Defines the default options for the Nextcloud File Picker.
|
|
* @returns {Object} The default configuration options for the file picker.
|
|
*/
|
|
static get defaultOptions() {
|
|
const baseOptions = super.defaultOptions;
|
|
return mergeObject(baseOptions, {
|
|
tabs: [{navSelector: ".tabs", contentSelector: ".content", initial: "nextcloud"}]
|
|
});
|
|
}
|
|
/**
|
|
* Clears the existing content in the FilePicker UI.
|
|
* This is used to refresh the UI and remove any previously displayed files/directories.
|
|
*/
|
|
clearFilePickerContent() {
|
|
const content = this.element.find('.filepicker-body');
|
|
content.empty();
|
|
}
|
|
/**
|
|
* Handles errors encountered during operations with Nextcloud.
|
|
* It determines the type of error and renders the appropriate UI to inform the user.
|
|
* @param {Error} error - The error object thrown during Nextcloud operations.
|
|
*/
|
|
handleNextcloudError(error) {
|
|
console.error('Error fetching Nextcloud files:', error);
|
|
if (!getSetting('url')) {
|
|
this._renderNextcloudErrorUI('urlNotSet');
|
|
} else if (!getSetting('userName') || !getSetting('appPassword')) {
|
|
this._renderNextcloudErrorUI('credentialsNotSet');
|
|
} else if (this._isCorsError(error)) {
|
|
this._renderNextcloudErrorUI('corsError');
|
|
} else {
|
|
this._renderNextcloudErrorUI('connectionError');
|
|
}
|
|
}
|
|
/**
|
|
* Fetches files from the specified path in Nextcloud using WebDAV API.
|
|
* @param {string} path - The path within the Nextcloud instance to fetch files from.
|
|
* @returns {Promise<Object>} A promise that resolves to the data of the fetched files.
|
|
*/
|
|
async _fetchNextcloudFiles(path) {
|
|
const endpoint = `remote.php/dav/files/${getSetting('userName')}/${getSetting('subdirectory')}/${path}`;
|
|
this.showSpinner();
|
|
const xmlResponse = await NextcloudFilePicker.makeNextcloudApiRequest(endpoint, 'PROPFIND', null, {}, {});
|
|
this.hideSpinner();
|
|
const data = this._parseWebDavResponse(xmlResponse);
|
|
data.path = path;
|
|
return data;
|
|
}
|
|
/**
|
|
* Fetches the unique file ID for a given file name in Nextcloud.
|
|
* @param {string} fileName - The name of the file to find the ID for.
|
|
* @returns {Promise<string|null>} A promise resolving to the file ID, or null if not found.
|
|
*/
|
|
async fetchFileId(fileName) {
|
|
const searchXml = `<?xml version="1.0"?>
|
|
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
|
<d:basicsearch>
|
|
<d:select>
|
|
<d:prop>
|
|
<oc:fileid/>
|
|
</d:prop>
|
|
</d:select>
|
|
<d:from>
|
|
<d:scope>
|
|
<d:href>/files/${getSetting('userName')}</d:href>
|
|
<d:depth>infinity</d:depth>
|
|
</d:scope>
|
|
</d:from>
|
|
<d:where>
|
|
<d:like>
|
|
<d:prop>
|
|
<d:displayname/>
|
|
</d:prop>
|
|
<d:literal>%${fileName}%</d:literal>
|
|
</d:like>
|
|
</d:where>
|
|
</d:basicsearch>
|
|
</d:searchrequest>`;
|
|
const endpoint = `remote.php/dav/`;
|
|
try {
|
|
this.showSpinner();
|
|
const xmlResponse = await NextcloudFilePicker.makeNextcloudApiRequest(endpoint, 'SEARCH', searchXml, { 'Content-Type': 'text/xml' });
|
|
this.hideSpinner();
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(xmlResponse, "application/xml");
|
|
const fileidElements = xmlDoc.querySelectorAll("oc\\:fileid, fileid");
|
|
let fileId = null;
|
|
if (fileidElements.length > 0) {
|
|
fileId = fileidElements[0].textContent;
|
|
}
|
|
return fileId;
|
|
} catch (error) {
|
|
console.error('Error fetching file ID:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Fetches an image from Nextcloud and converts it to a Base64 encoded string.
|
|
* @param {string} fileName - The name of the image file to fetch.
|
|
* @param {number} s - Size parameter for the image to fetch.
|
|
* @returns {Promise<string>} A promise that resolves to the Base64 encoded image data.
|
|
*/
|
|
async fetchImageAsBase64(fileName, s) {
|
|
try {
|
|
const fileId = await this.fetchFileId(fileName);
|
|
const previewEndpoint = `index.php/apps/webapppassword/core/preview?fileId=${fileId}&x=${s}&y=${s}`;
|
|
const imageBlob = await NextcloudFilePicker.makeNextcloudApiRequest(previewEndpoint, 'GET', null, {}, { responseType: 'blob' });
|
|
return convertBlobToBase64(imageBlob);
|
|
} catch (error) {
|
|
console.error('Error fetching image as Base64:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Renders the UI to display Nextcloud error messages based on the type of error encountered.
|
|
* @param {string} errorType - The type of error to render the UI for (e.g., 'urlNotSet', 'corsError').
|
|
*/
|
|
async _renderNextcloudErrorUI(errorType) {
|
|
let errorMessage = "";
|
|
let isCorsError = false;
|
|
let isUrlNotSet = false;
|
|
let isCredentialsNotSet = false;
|
|
let isOtherError = false;
|
|
switch (errorType) {
|
|
case 'urlNotSet':
|
|
isUrlNotSet = true;
|
|
break;
|
|
case 'credentialsNotSet':
|
|
isCredentialsNotSet = true;
|
|
break;
|
|
case 'corsError':
|
|
isCorsError = true;
|
|
break;
|
|
case 'connectionError':
|
|
isOtherError = true;
|
|
break;
|
|
}
|
|
const templateData = { isCorsError, isUrlNotSet, isCredentialsNotSet, isOtherError };
|
|
const renderedHtml = await renderTemplate('modules/nextcloud-filepicker/templates/nextcloud-error.html', templateData);
|
|
const content = this.element.find('.filepicker-body');
|
|
this.element.find('nav.tabs[aria-role="Form Tab Navigation"]').nextAll().remove();
|
|
this.element.find('section.filepicker-body').nextAll().remove();
|
|
content.html(renderedHtml);
|
|
}
|
|
/**
|
|
* Checks whether file upload is allowed to Nextcloud based on current settings and user permissions.
|
|
* @returns {boolean} True if upload is allowed, false otherwise.
|
|
*/
|
|
get canUpload() {
|
|
if ( this.activeSource === 'nextcloud' ) {
|
|
if ( this.type === "folder" ) return false;
|
|
if ( this.options.allowUpload === false ) return false;
|
|
return !game.user || game.user.can("FILES_UPLOAD");
|
|
} else {
|
|
return super.canUpload;
|
|
}
|
|
}
|
|
/**
|
|
* Renders the UI specifically for CORS (Cross-Origin Resource Sharing) errors encountered with Nextcloud.
|
|
*/
|
|
async _renderCorsErrorUI() {
|
|
const html = await renderTemplate("modules/nextcloud-filepicker/templates/cors-error.html");
|
|
const content = this.element.find('.filepicker-body');
|
|
this.element.find('nav.tabs[aria-role="Form Tab Navigation"]').nextAll().remove();
|
|
this.element.find('section.filepicker-body').nextAll().remove();
|
|
content.html(html);
|
|
}
|
|
/**
|
|
* Determines if a given error is related to CORS issues.
|
|
* @param {Error} error - The error object to evaluate.
|
|
* @returns {boolean} True if the error is a CORS error, false otherwise.
|
|
*/
|
|
_isCorsError(error) {
|
|
return error.message.includes('CORS') || error.message.includes('NetworkError');
|
|
}
|
|
/**
|
|
* Makes an API request to the Nextcloud server.
|
|
* @param {NextcloudFilePicker} nextcloudFilePicker - The instance of the NextcloudFilePicker making the request.
|
|
* @param {string} endpoint - The API endpoint relative to the Nextcloud base URL.
|
|
* @param {string} [method='GET'] - The HTTP method to use for the request.
|
|
* @param {Object|null} [data=null] - The data to send with the request.
|
|
* @param {Object} [headers={}] - Additional headers for the request.
|
|
* @param {Object} [options={}] - Additional options for the request.
|
|
* @param {boolean} [shouldWait=false] - Whether to show a loading spinner during the request.
|
|
* @returns {Promise} A promise that resolves to the response from the API request.
|
|
*/
|
|
static async makeNextcloudApiRequest(endpoint, method = 'GET', data = null, headers = {}, options = {}) {
|
|
const baseUrl = getSetting('url');
|
|
const userName = getSetting('userName');
|
|
const appPassword = getSetting('appPassword');
|
|
const url = `${baseUrl}/${endpoint}`;
|
|
const authHeader = 'Basic ' + btoa(userName + ':' + appPassword);
|
|
const defaultHeaders = {
|
|
'Authorization': authHeader,
|
|
'OCS-APIRequest': true
|
|
};
|
|
const combinedHeaders = { ...defaultHeaders, ...headers };
|
|
const requestOptions = {
|
|
method: method,
|
|
headers: combinedHeaders,
|
|
credentials: 'omit'
|
|
};
|
|
if (method === 'PUT') {
|
|
requestOptions.body = data;
|
|
}
|
|
if (method === 'SEARCH') {
|
|
requestOptions.body = data;
|
|
}
|
|
if (method === 'POST' && data !== null) {
|
|
const formData = new URLSearchParams();
|
|
for (const key in data) {
|
|
formData.append(key, data[key]);
|
|
}
|
|
requestOptions.body = formData;
|
|
}
|
|
try {
|
|
const response = await fetch(url, requestOptions);
|
|
if (!response.ok) {
|
|
throw new Error(`Network response was not ok (${response.status}): ${response.statusText}`);
|
|
}
|
|
if (options.responseType === 'blob') {
|
|
return await response.blob();
|
|
} else if (response.headers.get('Content-Type').includes('application/json')) {
|
|
return await response.json();
|
|
} else if (response.headers.get('Content-Type').includes('application/xml') || response.headers.get('Content-Type').includes('text/xml')) {
|
|
return await response.text();
|
|
} else {
|
|
return await response.text();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error making API request:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Displays a loading spinner in the file picker UI during processing or API requests.
|
|
* @param {NextcloudFilePicker} nextcloudFilePicker - The instance of the NextcloudFilePicker.
|
|
*/
|
|
showSpinner() {
|
|
const html = this.element;
|
|
const filePickerElement = html.find(".standard-form");
|
|
filePickerElement.css("filter", "blur(1px) brightness(0.75) contrast(0.75)");
|
|
const spinnerHtml = `<div class='spinner-overlay' style="width: 100%;height: 100%;position: absolute;z-index: 10;text-align: center;line-height: 100%;vertical-align: middle;display: flex;align-items: center;">
|
|
<i class="fa-solid fa-spinner-third fa-2xl" style="color: black;width: 100%; animation: fa-spin 0.5s infinite linear; font-size: 64px;"></i>
|
|
</div>`;
|
|
filePickerElement.parent().prepend(spinnerHtml);
|
|
}
|
|
/**
|
|
* Hides the loading spinner in the file picker UI after processing or API requests are complete.
|
|
* @param {NextcloudFilePicker} nextcloudFilePicker - The instance of the NextcloudFilePicker.
|
|
*/
|
|
hideSpinner() {
|
|
const html = this.element;
|
|
const filePickerElement = html.find(".standard-form");
|
|
filePickerElement.css("filter", "");
|
|
filePickerElement.parent().find('.spinner-overlay').remove();
|
|
}
|
|
/**
|
|
* Checks if a file or directory in Nextcloud has an existing public link.
|
|
* @param {string} path - The path of the file or directory to check.
|
|
* @returns {Promise<boolean|string[]|null>} A promise that resolves to the public link URL if it exists, a list of files if a directory, or null otherwise.
|
|
*/
|
|
async checkPublicLink(path) {
|
|
let isFile = true;
|
|
if(path.slice(-1) == "/") {
|
|
isFile = false
|
|
path=path.substring(0,-1);
|
|
}
|
|
let filePath = encodeURIComponent(path)
|
|
if(getSetting('subdirectory')) {
|
|
filePath = `${getSetting('subdirectory')}/${filePath}`;
|
|
}
|
|
const endpoint = `/index.php/apps/webapppassword/api/v1/shares?path=${filePath}&reshares=true&subfiles=false`;
|
|
try {
|
|
const response = await NextcloudFilePicker.makeNextcloudApiRequest(endpoint, 'GET');
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(response, "application/xml");
|
|
if ( isFile ) {
|
|
const urlElement = xmlDoc.querySelector("url");
|
|
if (urlElement) {
|
|
let fileName = encodeURIComponent(filePath.split('/').pop());
|
|
const url = urlElement.textContent;
|
|
return url + `/download/${fileName}`;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
else {
|
|
const urlElements = xmlDoc.querySelectorAll("element");
|
|
return Array.from(urlElements).map((a) => {
|
|
if ((a.querySelector("mimetype").textContent != 'httpd/unix-directory')) {
|
|
const file = a.querySelector("file_target").textContent.replace("/", "");
|
|
const path = a.querySelector("path").textContent.replace(file, "").replace(/\/$/,"").replace(/^\//,"");
|
|
const url = a.querySelector("url").textContent;
|
|
if (path == this.target) {
|
|
return file
|
|
}
|
|
}
|
|
}).filter((e) => { return e != undefined});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking public link:', error);
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Creates a public link for a file in Nextcloud if it doesn't already exist.
|
|
* @param {string} file - The path of the file to create a public link for.
|
|
* @returns {Promise<string|null>} A promise that resolves to the public link URL or null if unable to create.
|
|
*/
|
|
async createPublicLink(file) {
|
|
let fileName= encodeURIComponent(file.split('/').pop());
|
|
let filePath=file;
|
|
if(getSetting('subdirectory')) filePath = `${getSetting('subdirectory')}/${filePath}`
|
|
const endpoint = `/index.php/apps/webapppassword/api/v1/shares`;
|
|
const body = {
|
|
path: filePath,
|
|
shareType: 3,
|
|
permissions: 1
|
|
};
|
|
this.showSpinner();
|
|
const response = await NextcloudFilePicker.makeNextcloudApiRequest(endpoint, 'POST', body, {}, {});
|
|
this.hideSpinner();
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(response, "application/xml");
|
|
const urlElement = xmlDoc.querySelector("url");
|
|
if (urlElement) {
|
|
const url = urlElement.textContent;
|
|
return url + `/download/${fileName}`;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
/**
|
|
* Updates the file picker UI with icons indicating which files have public links.
|
|
*/
|
|
async updatePublicLinkIcons() {
|
|
const targetDirectory = this.source.target;
|
|
const filesWithLinks = await this.checkPublicLink(targetDirectory + "/");
|
|
if ( filesWithLinks ) {
|
|
this.element.find('.file').each($.proxy(function (index, element) {
|
|
const fileName = $(element).data('name');
|
|
if (filesWithLinks.includes(fileName) && !$(element).find('.fa-link').length) {
|
|
let innerHTML = '';
|
|
switch (this.displayMode) {
|
|
case "list":
|
|
$(element).append('<i style="color: #0082C9;" class="fas fa-link fa-solid fa-sm"></i>');
|
|
break;
|
|
case "thumbs":
|
|
innerHTML = $(element).find('.filename').html();
|
|
$(element).find('.filename').html(innerHTML + '<i style="color: #0082C9; margin-left:4px;" class="fas fa-link fa-solid fa-sm"></i>');
|
|
break;
|
|
case "tiles":
|
|
$(element).append('<i style="color: #0082C9;top: -34px;position: relative;font-size: 20px;left: 72px;" class="fas fa-link fa-solid fa-sm"></i>');
|
|
break;
|
|
case "images":
|
|
innerHTML = $(element).find('.filename').html();
|
|
$(element).find('.filename').html(innerHTML + '<i style="color: #0082C9; margin-left:4px;" class="fas fa-link fa-solid fa-sm"></i>');
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}, this));
|
|
}
|
|
}
|
|
/**
|
|
* Handles the submit event of the file picker. This includes creating public links for selected Nextcloud files if necessary.
|
|
* @param {Event} ev - The submit event object.
|
|
*/
|
|
async _onSubmit(ev) {
|
|
ev.preventDefault();
|
|
let path = ev.target.file.value;
|
|
if (!path) return ui.notifications.error("You must select a file to proceed.");
|
|
if (this.activeSource === "nextcloud") {
|
|
const publicLink = await this.checkPublicLink(path);
|
|
if (!publicLink) {
|
|
const proceed = await this.showConfirmationDialog();
|
|
if (proceed) {
|
|
try {
|
|
const target = await this.createPublicLink(path);
|
|
ui.notifications.info("A public link has been created for the file.");
|
|
path = target;
|
|
handleFileSelection(ev.target.file.value, path);
|
|
} catch (error) {
|
|
console.error('Error creating public link:', error);
|
|
ui.notifications.error("Failed to create a public link for the file.");
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
else {
|
|
path = publicLink;
|
|
handleFileSelection(ev.target.file.value, path);
|
|
}
|
|
}
|
|
if (this.field) {
|
|
this.field.value = path;
|
|
this.field.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
if (this.callback) this.callback(path, this);
|
|
return this.close();
|
|
}
|
|
/**
|
|
* Displays a confirmation dialog when creating a public link for a file.
|
|
* @returns {Promise<boolean>} A promise that resolves to true if the user confirms, false otherwise.
|
|
*/
|
|
async showConfirmationDialog() {
|
|
if (game.settings.get('nextcloud-filepicker', 'skipPublicLinkConfirmation')) {
|
|
return true;
|
|
}
|
|
return new Promise(resolve => {
|
|
let content = `
|
|
<p>In order for other players to view this image, a public link will need to be created. Do you wish to proceed?</p>
|
|
<div><input type="checkbox" id="skipConfirmation" name="skipConfirmation"><label for="skipConfirmation">Do not ask again</label></div>
|
|
`;
|
|
let d = new Dialog({
|
|
title: "Create Public Link",
|
|
content: content,
|
|
buttons: {
|
|
yes: {
|
|
icon: '<i class="fas fa-check"></i>',
|
|
label: "Yes",
|
|
callback: (html) => {
|
|
const skipConfirmation = html.find('#skipConfirmation').is(':checked');
|
|
setSetting('skipPublicLinkConfirmation', skipConfirmation);
|
|
resolve(true);
|
|
}
|
|
},
|
|
no: {
|
|
icon: '<i class="fas fa-times"></i>',
|
|
label: "No",
|
|
callback: () => resolve(false)
|
|
}
|
|
},
|
|
default: "no",
|
|
close: () => resolve(false)
|
|
});
|
|
d.render(true);
|
|
});
|
|
}
|
|
/**
|
|
* Handles the upload of files to Nextcloud.
|
|
* @param {string} source - The source from which the file is being uploaded.
|
|
* @param {string} path - The path where the file will be uploaded.
|
|
* @param {File} file - The file to be uploaded.
|
|
* @param {Object} [body={}] - Additional body parameters for the upload request.
|
|
* @param {Object} [options={}] - Additional options for the upload request.
|
|
* @returns {Promise<Object>} A promise that resolves with upload response details.
|
|
*/
|
|
static async upload(source, path, file, body={}, options={}) {
|
|
if (source === "nextcloud") {
|
|
const endpoint = `remote.php/dav/files/${getSetting('userName')}/${getSetting('subdirectory')}/${path}/${file.name}`;
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = async () => {
|
|
try {
|
|
const arrayBuffer = reader.result;
|
|
await NextcloudFilePicker.makeNextcloudApiRequest(endpoint, 'PUT', arrayBuffer, {}, {});
|
|
resolve({ path: endpoint });
|
|
} catch (error) {
|
|
console.error('Error uploading to Nextcloud:', error);
|
|
reject(error);
|
|
}
|
|
};
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
} else {
|
|
return super.upload(source, path, file, body, options);
|
|
}
|
|
}
|
|
/**
|
|
* Presents a dialog to create a new directory in the Nextcloud storage.
|
|
* @param {Object} source - The data source being browsed.
|
|
* @private
|
|
*/
|
|
_createDirectoryDialog(source) {
|
|
if (this.activeSource === "nextcloud") {
|
|
const form = `<form><div class="form-group">
|
|
<label>Directory Name</label>
|
|
<input type="text" name="dirname" placeholder="directory-name" required/>
|
|
</div></form>`;
|
|
return Dialog.confirm({
|
|
title: game.i18n.localize("FILES.CreateSubfolder"),
|
|
content: form,
|
|
yes: async html => {
|
|
const dirname = html.querySelector("input").value;
|
|
const path = [source.target, dirname].filterJoin("/");
|
|
try {
|
|
await this.constructor.createDirectory(this.activeSource, path, {nextcloudFilePicker: this});
|
|
} catch ( err ) {
|
|
ui.notifications.error(err.message);
|
|
}
|
|
return this.browse(this.target);
|
|
},
|
|
options: {jQuery: false}
|
|
});
|
|
}
|
|
else {
|
|
return super._createDirectoryDialog(source);
|
|
}
|
|
}
|
|
/**
|
|
* Creates a new directory in the Nextcloud storage.
|
|
* @param {string} source - The source in which the directory is being created.
|
|
* @param {string} target - The target path for the new directory.
|
|
* @param {Object} [options={}] - Additional options for the directory creation.
|
|
* @returns {Promise<boolean>} A promise that resolves to true if the directory is successfully created, false otherwise.
|
|
*/
|
|
static async createDirectory(source, target, options = {}) {
|
|
if (source === "nextcloud") {
|
|
let fullPath = target;
|
|
const endpoint = `remote.php/dav/files/${getSetting('userName')}/${getSetting('subdirectory')}/${fullPath}`;
|
|
try {
|
|
await NextcloudFilePicker.makeNextcloudApiRequest(endpoint, 'MKCOL', null, {}, {});
|
|
ui.notifications.info(`Directory created: ${fullPath}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error creating directory:', error);
|
|
ui.notifications.error(`Failed to create directory: ${fullPath}`);
|
|
return false;
|
|
}
|
|
} else {
|
|
return super.createDirectory(source, target, options);
|
|
}
|
|
}
|
|
/**
|
|
* Browses the target directory in Nextcloud and updates the file picker UI with the results.
|
|
* @param {string} [target=""] - The target directory to browse.
|
|
* @param {Object} [options={}] - Additional browsing options.
|
|
* @returns {Promise<Object>} A promise that resolves to the browsing result data.
|
|
*/
|
|
async browse(target = "", options = {}) {
|
|
if (this.activeSource === "nextcloud") {
|
|
const data = await this._fetchNextcloudFiles(target);
|
|
const convertedData = this._convertToBrowseResults(data);
|
|
let filteredFiles = convertedData.files;
|
|
if (this.type !== "any" && this.extensions.length) {
|
|
filteredFiles = convertedData.files.filter(file => {
|
|
return this.extensions.some(ext => file.name.toLowerCase().endsWith(ext));
|
|
});
|
|
}
|
|
this.result = {
|
|
target: target,
|
|
private: false,
|
|
gridSize: null,
|
|
dirs: convertedData.dirs.map(dir => {
|
|
let dirName = dir.path;
|
|
return dirName;
|
|
}),
|
|
privateDirs: [],
|
|
files: filteredFiles.map(file => file.url),
|
|
extensions: this.extensions || []
|
|
};
|
|
this.constructor.LAST_BROWSED_DIRECTORY = this.result.target;
|
|
this._loaded = true;
|
|
try {
|
|
this.source.target = target;
|
|
this.render(true);
|
|
return this.result;
|
|
}
|
|
catch (error){
|
|
}
|
|
} else {
|
|
const super_result=super.browse(target, options);
|
|
return super_result;
|
|
}
|
|
}
|
|
/**
|
|
* Retrieves data for the file picker UI, including files and directories from the Nextcloud source.
|
|
* @param {Object} [options={}] - Options for retrieving data.
|
|
* @returns {Promise<Object>} A promise that resolves to the data needed for rendering the file picker UI.
|
|
*/
|
|
async getData(options = {}) {
|
|
let data = await super.getData(options);
|
|
if (this.activeSource === "nextcloud") {
|
|
const result = this.result;
|
|
const source = this.source;
|
|
let target = decodeURIComponent(source.target);
|
|
const isS3 = false;
|
|
let dirs = result.dirs.map(d => ({
|
|
name: decodeURIComponent(d.split("/").pop()),
|
|
path: d,
|
|
private: result.private || result.privateDirs.includes(d)
|
|
}));
|
|
dirs = dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
let files = result.files.map(f => {
|
|
let img = "";
|
|
if ( VideoHelper.hasVideoExtension(f) ) img = "icons/svg/video.svg";
|
|
else if ( AudioHelper.hasAudioExtension(f) ) img = "icons/svg/sound.svg";
|
|
else if ( !ImageHelper.hasImageExtension(f) ) img = "icons/svg/book.svg";
|
|
else {
|
|
img = NextcloudFilePicker.thumbnailCache[f] || img;
|
|
}
|
|
return {
|
|
name: decodeURIComponent(f.split("/").pop()),
|
|
url: f,
|
|
img: img
|
|
};
|
|
});
|
|
if (["thumbs", "tiles", "images"].includes(this.displayMode)) {
|
|
files.forEach((file, index) => {
|
|
if (ImageHelper.hasImageExtension(file.name) && !NextcloudFilePicker.thumbnailCache[file.url]) {
|
|
this.fetchImageAsBase64(file.name, 200).then(base64Image => {
|
|
NextcloudFilePicker.thumbnailCache[file.url] = base64Image;
|
|
files[index].img = base64Image;
|
|
this.updateImageInDOM(file.name, base64Image);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
data = {
|
|
bucket: isS3 ? source.bucket : null,
|
|
canGoBack: this.activeSource !== "",
|
|
canUpload: this.canUpload,
|
|
canSelect: !this.options.tileSize,
|
|
cssClass: [this.displayMode, result.private ? "private": "public"].join(" "),
|
|
dirs: dirs,
|
|
displayMode: this.displayMode,
|
|
extensions: this.extensions,
|
|
files: files,
|
|
isS3: isS3,
|
|
noResults: dirs.length + files.length === 0,
|
|
selected: this.type === "folder" ? target : this.request,
|
|
source: source,
|
|
sources: this.sources,
|
|
target: target,
|
|
tileSize: this.options.tileSize ? (FilePicker.LAST_TILE_SIZE || canvas.dimensions.size) : null,
|
|
user: game.user,
|
|
submitText: this.type === "folder" ? "FILES.SelectFolder" : "FILES.SelectFile",
|
|
favorites: FilePicker.favorites
|
|
};
|
|
}
|
|
if (data.selected && this.isNextcloudUrl(data.selected)) {
|
|
|
|
let nextcloudFilePaths = getSetting("nextcloudFilePaths");
|
|
const relativePath = nextcloudFilePaths[data.selected];
|
|
if (relativePath) {
|
|
data.selected = relativePath;
|
|
} else {
|
|
data.selected = this.extractFileName(data.selected)
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
/**
|
|
* Checks if a given path is a Nextcloud URL.
|
|
* @param {string} path - The path or URL to check.
|
|
* @returns {boolean} True if it's a Nextcloud URL, false otherwise.
|
|
*/
|
|
isNextcloudUrl(path) {
|
|
return path.includes(getSetting('url'));
|
|
}
|
|
/**
|
|
* Extracts the filename from a full URL or path.
|
|
* @param {string} path - The full URL or path to the file.
|
|
* @returns {string} The extracted filename.
|
|
*/
|
|
extractFileName(path) {
|
|
return decodeURIComponent(path.split('/').pop().split('?')[0]);
|
|
}
|
|
/**
|
|
* Updates the image source in the DOM for a given file name with the provided Base64 image data.
|
|
* @param {string} fileName - The name of the file for which to update the image.
|
|
* @param {string} base64Image - The Base64 encoded image data.
|
|
*/
|
|
updateImageInDOM(fileName, base64Image) {
|
|
const fileElements = this.element.find(`.file[data-name="${fileName}"]`);
|
|
fileElements.each(function() {
|
|
const imgElement = $(this).find('img');
|
|
if (imgElement.length) {
|
|
imgElement.attr('src', base64Image);
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Navigates back to the parent directory in the Nextcloud file picker UI.
|
|
*/
|
|
goBack() {
|
|
if (this.activeSource === "nextcloud") {
|
|
let parts = this.sources.nextcloud.target.split('/').filter(Boolean);
|
|
parts.pop();
|
|
let parentPath = parts.join('/') || '';
|
|
this.browse(parentPath);
|
|
}
|
|
}
|
|
/**
|
|
* Converts Nextcloud XML data to a format compatible with FilePicker results.
|
|
* @param {Object} nextcloudData - The Nextcloud data to convert.
|
|
* @returns {Object} Formatted results with 'files' and 'dirs' arrays.
|
|
*/
|
|
_convertToBrowseResults(nextcloudData) {
|
|
let dirs = [], files = [];
|
|
for (let dir of nextcloudData.directories) {
|
|
if (dir.href !== nextcloudData.path) {
|
|
dirs.push({
|
|
name: decodeURIComponent(dir.name),
|
|
path: decodeURIComponent(dir.href),
|
|
private: false
|
|
});
|
|
}
|
|
}
|
|
for (let file of nextcloudData.files) {
|
|
let thumbnail = file.thumbnail || (f => {
|
|
if (VideoHelper.hasVideoExtension(f)) return "icons/svg/video.svg";
|
|
else if (AudioHelper.hasAudioExtension(f)) return "icons/svg/sound.svg";
|
|
else if (!ImageHelper.hasImageExtension(f)) return "icons/svg/book.svg";
|
|
})(file.name);
|
|
files.push({
|
|
name: decodeURIComponent(file.name),
|
|
path: decodeURIComponent(file.href),
|
|
url: decodeURIComponent(file.href),
|
|
img: thumbnail,
|
|
});
|
|
}
|
|
return { dirs, files };
|
|
}
|
|
removeDavRootDir(dirHref) {
|
|
const removalString=`/remote.php/dav/files/${getSetting('userName')}/${getSetting('subdirectory')}/`
|
|
const newHref=dirHref.substring(removalString.length-1,dirHref.length);
|
|
return newHref;
|
|
}
|
|
/**
|
|
* Parses the XML response from the Nextcloud WebDAV API to extract file and directory information.
|
|
* @param {string} xml - The XML response as a string.
|
|
* @returns {Object} An object containing arrays of files and directories extracted from the response.
|
|
*/
|
|
_parseWebDavResponse(xml) {
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(xml, "application/xml");
|
|
const files = [];
|
|
const directories = [];
|
|
const responses = xmlDoc.querySelectorAll("d\\:response, response");
|
|
responses.forEach(response => {
|
|
const href = this.removeDavRootDir(response.querySelector("d\\:href, href").textContent.replace(/\/$/,''));
|
|
const resType = response.querySelector("d\\:resourcetype, resourcetype");
|
|
const isDirectory = resType.querySelector("d\\:collection, collection") !== null;
|
|
const name = decodeURIComponent(href.split("/").pop());
|
|
if (isDirectory) {
|
|
directories.push({ name, href });
|
|
} else {
|
|
files.push({ name, href });
|
|
}
|
|
});
|
|
return { files, directories };
|
|
}
|
|
/**
|
|
* Initializes and opens the FilePicker from a button click, handling any necessary setup.
|
|
* @param {HTMLElement} button - The button element that triggered the FilePicker.
|
|
* @returns {FilePicker} The instance of the FilePicker initialized from the button.
|
|
*/
|
|
static fromButton(button) {
|
|
let type = button.getAttribute("data-type");
|
|
const form = button.form;
|
|
const field = form[button.dataset.target] || null;
|
|
let current = field?.value || "";
|
|
if (isNextcloudUrl(current)) {
|
|
current = this.extractFileName(current);
|
|
}
|
|
return new FilePicker({ field, type, current, button });
|
|
}
|
|
/**
|
|
* Renders the FilePicker interface, updating the UI based on the current state and source.
|
|
* @param {boolean} [force=false] - Whether to force the rendering of the FilePicker.
|
|
* @param {Object} [options={}] - Options for rendering the FilePicker.
|
|
*/
|
|
async render(force = false, options = {}) {
|
|
super.render(force, options);
|
|
setTimeout(() => {
|
|
if (this.activeSource === "nextcloud") {
|
|
this.updatePublicLinkIcons();
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
/**
|
|
* Retrieves the value of a specified setting for the Nextcloud integration.
|
|
* @param {string} setting - The key name of the setting to retrieve.
|
|
* @returns {*} The value of the requested setting.
|
|
*/
|
|
function getSetting(setting) {
|
|
return game.settings.get('nextcloud-filepicker', setting);
|
|
}
|
|
|
|
function setSetting(setting, value) {
|
|
return game.settings.set('nextcloud-filepicker', setting, value);
|
|
}
|
|
/**
|
|
* Initializes the module, sets up Nextcloud integration settings, and configures the NextcloudFilePicker.
|
|
*/
|
|
function initializeModule() {
|
|
registerSettings();
|
|
try {
|
|
} catch (error) {
|
|
console.error(`Error initializing ${MODULE_NAME}:`, error);
|
|
}
|
|
}
|
|
/**
|
|
* Handles the selection of a file, updating the Nextcloud file paths setting accordingly.
|
|
* @param {string} filePath - The selected file path.
|
|
* @param {string} nextcloudUrl - The Nextcloud URL associated with the file.
|
|
*/
|
|
function handleFileSelection(filePath, nextcloudUrl) {
|
|
let nextcloudFilePaths = getSetting("nextcloudFilePaths");
|
|
nextcloudFilePaths[nextcloudUrl] = filePath;
|
|
setSetting("nextcloudFilePaths", nextcloudFilePaths);
|
|
}
|
|
/**
|
|
* Logs a message for the module with a specified level of importance.
|
|
* @param {string} level - The log level ('debug', 'info', 'error').
|
|
* @param {string} message - The message to log.
|
|
* @param {string} [context='General'] - Context or category of the message.
|
|
* @param {Error} [error=null] - Optional error object for detailed logging.
|
|
*/
|
|
function logMessage(level, message, context = 'General', error = null) {
|
|
const timestamp = new Date().toISOString();
|
|
const logMessage = `Nextcloud Foundry | ${context} - ${message}`;
|
|
switch (level) {
|
|
case 'debug':
|
|
console.debug(logMessage);
|
|
break;
|
|
case 'info':
|
|
console.info(logMessage);
|
|
break;
|
|
case 'error':
|
|
console.error(logMessage, error);
|
|
let userFriendlyMessage = `An error occurred in ${context}. Please check the console for more details.`;
|
|
break;
|
|
default:
|
|
}
|
|
}
|
|
/**
|
|
* Resizes an image blob to a specific maximum size. It maintains the aspect ratio of the image and uses the HTML canvas for resizing.
|
|
* The function creates an Image from the blob, then draws it onto a canvas with the new size, and finally converts the canvas back to a blob.
|
|
* @param {Blob} blob - The image blob to be resized.
|
|
* @param {number} maxSize - The maximum width or height of the image. The image will be scaled to maintain aspect ratio.
|
|
* @returns {Promise<Blob>} A promise that resolves with the resized image as a Blob object.
|
|
*/
|
|
async function resizeImage(blob, maxSize) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
let width = img.width;
|
|
let height = img.height;
|
|
const aspectRatio = width / height;
|
|
if (width > height && width > maxSize) {
|
|
width = maxSize;
|
|
height = Math.round(width / aspectRatio);
|
|
} else if (height > maxSize) {
|
|
height = maxSize;
|
|
width = Math.round(height * aspectRatio);
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
canvas.toBlob(resolve, 'image/png');
|
|
};
|
|
img.onerror = reject;
|
|
img.src = URL.createObjectURL(blob);
|
|
});
|
|
}
|
|
/**
|
|
* Converts a Blob object to a Base64 encoded string.
|
|
* @param {Blob} blob - The Blob object to be converted.
|
|
* @returns {Promise<string>} A promise that resolves to the Base64 encoded string.
|
|
*/
|
|
function convertBlobToBase64(blob) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
/**
|
|
* Constructs a full WebDAV URL for a given relative path using the Nextcloud base URL from settings.
|
|
* @param {string} relativePath - The relative path to append to the Nextcloud base URL.
|
|
* @returns {string} The full WebDAV URL.
|
|
*/
|
|
function constructWebDavUrl(relativePath) {
|
|
const baseUrl = game.settings.get('nextcloud-filepicker', 'url');
|
|
return `${baseUrl}/${relativePath}`;
|
|
}
|
|
/**
|
|
* Initialization code to set up the module. Registers the Nextcloud settings and integrates the NextcloudFilePicker.
|
|
*/
|
|
Hooks.once("init", () => {
|
|
if(!game.data.files.storages.includes("nextcloud")) {
|
|
game.data.files.storages.push("nextcloud");
|
|
}
|
|
registerSettings();
|
|
FilePicker = NextcloudFilePicker;
|
|
});
|