WebDAVClient

webdav简单客户端,支持创建文件,列目录,读取文件,写入文件,删除文件,移动/复制文件,判断文件是否存在等

Version vom 30.10.2025. Aktuellste Version

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyfork.org/scripts/554239/1686624/WebDAVClient.js

class WebDAVClient {
    constructor(config) {
        this.baseUrl = config.url.replace(/\/$/, '');
        this.username = config.username;
        this.password = config.password;
        this.authHeader = 'Basic ' + btoa(`${this.username}:${this.password}`);
    }

    _getFullUrl(path) {
        const cleanPath = path.startsWith('/') ? path : '/' + path;
        return this.baseUrl + cleanPath;
    }

    /**
     * 发送 WebDAV 请求 (使用 GM_xmlhttpRequest)
     * @param {string} method - HTTP 方法
     * @param {string} path - 路径
     * @param {Object} options - 其他选项
     * @returns {Promise<Object>} 包含 ok, status, statusText 和响应内容方法的对象
     */
    async _request(method, path, options = {}) {
        const url = this._getFullUrl(path);
        const headers = {
            'Authorization': this.authHeader,
            ...options.headers
        };

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: method,
                url: url,
                headers: headers,
                data: options.body,
                responseType: options.responseType || 'text',
                onload: function(response) {
                    // 模拟 fetch 的 Response 对象
                    const mockResponse = {
                        ok: response.status >= 200 && response.status < 300,
                        status: response.status,
                        statusText: response.statusText,
                        headers: response.responseHeaders,
                        // 添加响应内容获取方法
                        text: async () => response.responseText,
                        json: async () => JSON.parse(response.responseText),
                        arrayBuffer: async () => {
                            // 如果需要二进制数据,需要设置 responseType: 'arraybuffer'
                            if (response.response instanceof ArrayBuffer) {
                                return response.response;
                            }
                            // 否则尝试从文本转换
                            const encoder = new TextEncoder();
                            return encoder.encode(response.responseText).buffer;
                        }
                    };
                    resolve(mockResponse);
                },
                onerror: function(error) {
                    console.error(error);
                    reject(new Error(`请求失败: ${error.statusText || error}`));
                },
                ontimeout: function() {
                    reject(new Error('请求超时'));
                }
            });
        });
    }

    async exists(path) {
        try {
            const response = await this._request('HEAD', path);
            return response.ok;
        } catch (error) {
            console.error('exists 检查失败:', error);
            return false;
        }
    }

    async createDirectory(path, options = { recursive: true }) {
        try {
            if (options.recursive) {
                const parts = path.split('/').filter(p => p);
                let currentPath = '';

                for (const part of parts) {
                    currentPath += '/' + part;
                    const exists = await this.exists(currentPath);

                    if (!exists) {
                        const response = await this._request('MKCOL', currentPath);
                        if (!response.ok && response.status !== 405) {
                            throw new Error(`创建目录失败: ${response.status} ${response.statusText}`);
                        }
                    }
                }
            } else {
                const response = await this._request('MKCOL', path);
                if (!response.ok && response.status !== 405) {
                    throw new Error(`创建目录失败: ${response.status} ${response.statusText}`);
                }
            }
        } catch (error) {
            console.error('createDirectory 失败:', error);
            throw error;
        }
    }

    async getFileContents(path, options = { format: 'text' }) {
        try {
            const requestOptions = {};
            if (options.format === 'binary') {
                requestOptions.responseType = 'arraybuffer';
            }

            const response = await this._request('GET', path, requestOptions);

            if (!response.ok) {
                throw new Error(`获取文件失败: ${response.status} ${response.statusText}`);
            }

            switch (options.format) {
                case 'text':
                    return await response.text();
                case 'binary':
                    return await response.arrayBuffer();
                case 'json':
                    return await response.json();
                default:
                    return await response.text();
            }
        } catch (error) {
            console.error('getFileContents 失败:', error);
            throw error;
        }
    }

    async putFileContents(path, content, options = { overwrite: true }) {
        try {
            if (!options.overwrite) {
                const exists = await this.exists(path);
                if (exists) {
                    throw new Error('文件已存在,且未设置覆盖选项');
                }
            }

            const headers = {};
            if (options.contentType) {
                headers['Content-Type'] = options.contentType;
            } else if (typeof content === 'string') {
                headers['Content-Type'] = 'text/plain; charset=utf-8';
            }

            const response = await this._request('PUT', path, {
                headers,
                body: content
            });

            if (!response.ok) {
                throw new Error(`上传文件失败: ${response.status} ${response.statusText}`);
            }
        } catch (error) {
            console.error('putFileContents 失败:', error);
            throw error;
        }
    }

    async getDirectoryContents(path, options = { recursive: false }) {
        try {
            // 支持传入内部 _visitedPaths 来避免循环
            options = options || {};
            if (!options._visitedPaths) options._visitedPaths = new Set();

            // 规范化并防止重复访问
            let normalizedRequestPath = path.startsWith('/') ? path : '/' + path;
            // 去掉尾部斜杠
            normalizedRequestPath = normalizedRequestPath.replace(/\/+$/, '');

            // 如果已访问过则返回空
            if (options._visitedPaths.has(normalizedRequestPath)) {
                return [];
            }
            options._visitedPaths.add(normalizedRequestPath);

            const response = await this._request('PROPFIND', normalizedRequestPath, {
                headers: {
                    'Depth': '1'
                }
            });

            if (!response.ok) {
                throw new Error(`列出目录失败: ${response.status} ${response.statusText}`);
            }

            const text = await response.text();
            const parser = new DOMParser();
            const xmlDoc = parser.parseFromString(text, 'text/xml');

            const items = [];

            // === 修改开始(使用命名空间无关的查找并正确处理 D: 前缀) ===
            // 使用 getElementsByTagNameNS('*', ...) 来匹配任意命名空间的 response 节点
            let responses = xmlDoc.getElementsByTagNameNS('*', 'response');
            // 规范化目标 pathname(便于比较),以 baseUrl 为基础
            let targetPathname;
            try {
                targetPathname = new URL(this._getFullUrl(normalizedRequestPath)).pathname.replace(/\/+$/, '');
            } catch (e) {
                targetPathname = normalizedRequestPath.replace(/\/+$/, '');
            }

            for (let i = 0; i < responses.length; i++) {
                const resp = responses[i];

                // href 节点(任意命名空间)
                let hrefEl = resp.getElementsByTagNameNS('*', 'href')[0];
                if (!hrefEl) {
                    // 兜底:在子节点中查 localName 为 'href'
                    for (let k = 0; k < resp.childNodes.length; k++) {
                        const cn = resp.childNodes[k];
                        if (cn && cn.localName === 'href') {
                            hrefEl = cn;
                            break;
                        }
                    }
                }
                if (!hrefEl || !hrefEl.textContent) continue;
                const hrefRaw = hrefEl.textContent.trim();

                // 用 baseUrl 做 base 来规范化 href(处理相对或绝对 href)
                let hrefPathname;
                try {
                    const hrefUrl = new URL(hrefRaw, this.baseUrl);
                    hrefPathname = hrefUrl.pathname.replace(/\/+$/, '');
                } catch (e) {
                    hrefPathname = hrefRaw.replace(/\/+$/, '');
                }

                // 跳过目标目录自身条目
                if (hrefPathname === targetPathname) {
                    continue;
                }

                // 判断是否为 collection(目录)
                let isCollection = false;
                const resTypeEl = resp.getElementsByTagNameNS('*', 'resourcetype')[0];
                if (resTypeEl) {
                    for (let m = 0; m < resTypeEl.childNodes.length; m++) {
                        const child = resTypeEl.childNodes[m];
                        if (child && child.localName === 'collection') {
                            isCollection = true;
                            break;
                        }
                    }
                }

                // 解析 filename(取 hrefPathname 最后一段)
                const parts = hrefPathname.split('/').filter(p => p);
                const filename = parts.length ? decodeURIComponent(parts.pop()) : '';
                items.push({
                    filename,
                    path: hrefRaw,
                    type: isCollection ? 'directory' : 'file',
                    _hrefPathname: hrefPathname //内部使用,便于递归时计算相对路径
                });
            }
            // === 修改结束 ===

            // === 新增修改:过滤 macOS AppleDouble 文件(以 ._ 开头)和 .DS_Store,然后返回 ===
            let filtered = items.filter(i => {
                if (!i.filename) return false;
                // 过滤以 ._ 开头的 AppleDouble 文件和 .DS_Store
                return !(i.filename.startsWith('._') || i.filename === '.DS_Store');
            });

            // === 新增:递归合并子目录(当 options.recursive 为 true 时) ===
            if (options.recursive) {
                // 计算 base path 的 pathname(无尾斜杠),用于把 hrefPathname 转回相对于 baseUrl 的 path 参数
                let basePathname = '';
                try {
                    basePathname = new URL(this.baseUrl).pathname.replace(/\/+$/, '');
                } catch (e) {
                    basePathname = '';
                }

                // 收集要递归的目录
                const dirs = filtered.filter(i => i.type === 'directory');

                for (const dir of dirs) {
                    // dir._hrefPathname 是形如 '/dav/cursor-chat-history/sub'
                    let childHrefPath = dir._hrefPathname || dir.path;
                    // 把 basePathname 前缀剥离,得到相对于 this.baseUrl 的路径(以 / 开头)
                    let childRelative;
                    if (basePathname && childHrefPath.startsWith(basePathname)) {
                        childRelative = childHrefPath.slice(basePathname.length);
                        if (!childRelative.startsWith('/')) childRelative = '/' + childRelative;
                    } else {
                        childRelative = childHrefPath;
                    }
                    // 调用自身递归(传入同一个 options._visitedPaths 集合以防环)
                    const childItems = await this.getDirectoryContents(childRelative, options);
                    // childItems 已经过滤了 AppleDouble 等
                    // 把子项并入 filtered(保持扁平结构)
                    filtered = filtered.concat(childItems);
                }
            }

            // 最终返回:移除内部字段 _hrefPathname
            const result = filtered.map(({ _hrefPathname, ...rest }) => rest);
            return result;
            // === 修改结束(过滤与递归) ===

        } catch (error) {
            console.error('getDirectoryContents 失败:', error);
            throw error;
        }
    }

    async deleteFile(path) {
        try {
            const response = await this._request('DELETE', path);
            if (!response.ok) {
                throw new Error(`删除失败: ${response.status} ${response.statusText}`);
            }
        } catch (error) {
            console.error('deleteFile 失败:', error);
            throw error;
        }
    }

    async moveFile(fromPath, toPath, options = { overwrite: false }) {
        try {
            const response = await this._request('MOVE', fromPath, {
                headers: {
                    'Destination': this._getFullUrl(toPath),
                    'Overwrite': options.overwrite ? 'T' : 'F'
                }
            });

            if (!response.ok) {
                throw new Error(`移动文件失败: ${response.status} ${response.statusText}`);
            }
        } catch (error) {
            console.error('moveFile 失败:', error);
            throw error;
        }
    }

    async copyFile(fromPath, toPath, options = { overwrite: false }) {
        try {
            const response = await this._request('COPY', fromPath, {
                headers: {
                    'Destination': this._getFullUrl(toPath),
                    'Overwrite': options.overwrite ? 'T' : 'F'
                }
            });

            if (!response.ok) {
                throw new Error(`复制文件失败: ${response.status} ${response.statusText}`);
            }
        } catch (error) {
            console.error('copyFile 失败:', error);
            throw error;
        }
    }
}