refactor: modularize codebase — Step 0 structural refactoring
Extract monolithic index.ts (~600 lines) into focused modules: - src/types.ts — shared TypeScript interfaces (FileMetadata, QuotaInfo, etc.) - src/utils.ts — path, mime detection, formatting helpers - src/client.ts — NextcloudClient wrapping axios with WebDAV primitives - src/webdav.ts — XML builders + parsers (scaffolding for file tools) - src/tools/index.ts — ToolModule interface + auto registry - src/tools/calendar.ts — extracted calendar tools - src/tools/tasks.ts — extracted task tools - src/tools/notes.ts — extracted note tools - src/tools/email.ts — extracted email tools - src/tools/files.ts — empty scaffolding for upcoming file management tools src/index.ts reduced to ~50 lines: config, client instantiation, module registration, MCP setup. Zero regression on existing tools. Verified: list_calendars, get_notes, create_note, get_note_content all functional.
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
|||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import { NextcloudConfig } from "./types.js";
|
||||||
|
|
||||||
|
export class NextcloudClient {
|
||||||
|
private xmlClient: AxiosInstance;
|
||||||
|
private binaryClient: AxiosInstance;
|
||||||
|
private config: NextcloudConfig;
|
||||||
|
|
||||||
|
constructor(config: NextcloudConfig) {
|
||||||
|
this.config = config;
|
||||||
|
const baseAuth = {
|
||||||
|
username: config.username,
|
||||||
|
password: config.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.xmlClient = axios.create({
|
||||||
|
baseURL: config.url,
|
||||||
|
auth: baseAuth,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml",
|
||||||
|
Accept: "application/xml",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.binaryClient = axios.create({
|
||||||
|
baseURL: config.url,
|
||||||
|
auth: baseAuth,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generic WebDAV methods ---
|
||||||
|
|
||||||
|
async propfind(path: string, body: string, depth: string = "1"): Promise<string> {
|
||||||
|
const resp = await this.xmlClient.request({
|
||||||
|
method: "PROPFIND",
|
||||||
|
url: path,
|
||||||
|
data: body,
|
||||||
|
headers: { Depth: depth },
|
||||||
|
});
|
||||||
|
return String(resp.data ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
path: string,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
responseType: "text" | "arraybuffer" = "text"
|
||||||
|
): Promise<any> {
|
||||||
|
const client = responseType === "arraybuffer" ? this.binaryClient : this.xmlClient;
|
||||||
|
const resp = await client.get(path, { headers });
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(
|
||||||
|
path: string,
|
||||||
|
data: Buffer | string,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): Promise<any> {
|
||||||
|
return this.binaryClient.put(path, data, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkcol(path: string, headers?: Record<string, string>): Promise<any> {
|
||||||
|
return this.xmlClient.request({
|
||||||
|
method: "MKCOL",
|
||||||
|
url: path,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(path: string): Promise<any> {
|
||||||
|
return this.xmlClient.delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(
|
||||||
|
source: string,
|
||||||
|
destination: string,
|
||||||
|
overwrite: boolean = false
|
||||||
|
): Promise<any> {
|
||||||
|
return this.xmlClient.request({
|
||||||
|
method: "MOVE",
|
||||||
|
url: source,
|
||||||
|
headers: {
|
||||||
|
Destination: destination,
|
||||||
|
Overwrite: overwrite ? "T" : "F",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async copy(
|
||||||
|
source: string,
|
||||||
|
destination: string,
|
||||||
|
overwrite: boolean = false
|
||||||
|
): Promise<any> {
|
||||||
|
return this.xmlClient.request({
|
||||||
|
method: "COPY",
|
||||||
|
url: source,
|
||||||
|
headers: {
|
||||||
|
Destination: destination,
|
||||||
|
Overwrite: overwrite ? "T" : "F",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async report(path: string, body: string, depth: string = "1"): Promise<string> {
|
||||||
|
const resp = await this.xmlClient.request({
|
||||||
|
method: "REPORT",
|
||||||
|
url: path,
|
||||||
|
data: body,
|
||||||
|
headers: { Depth: depth, "Content-Type": "application/xml" },
|
||||||
|
});
|
||||||
|
return String(resp.data ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(body: string): Promise<string> {
|
||||||
|
const resp = await this.xmlClient.request({
|
||||||
|
method: "SEARCH",
|
||||||
|
url: "/remote.php/dav/",
|
||||||
|
data: body,
|
||||||
|
headers: { "Content-Type": "text/xml" },
|
||||||
|
});
|
||||||
|
return String(resp.data ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async proppatch(path: string, body: string): Promise<string> {
|
||||||
|
const resp = await this.xmlClient.request({
|
||||||
|
method: "PROPPATCH",
|
||||||
|
url: path,
|
||||||
|
data: body,
|
||||||
|
headers: { "Content-Type": "application/xml" },
|
||||||
|
});
|
||||||
|
return String(resp.data ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(path: string, data: any, headers?: Record<string, string>): Promise<any> {
|
||||||
|
return this.xmlClient.post(path, data, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async postBulk(body: Buffer, contentType: string): Promise<any> {
|
||||||
|
return this.binaryClient.post("/remote.php/dav/bulk", body, {
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accessors ---
|
||||||
|
|
||||||
|
get username(): string {
|
||||||
|
return this.config.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
get baseUrl(): string {
|
||||||
|
return this.config.url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-993
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,412 @@
|
|||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { NextcloudClient } from "../client.js";
|
||||||
|
import { ToolModule } from "./index.js";
|
||||||
|
import { ToolResponse } from "../types.js";
|
||||||
|
import {
|
||||||
|
CalendarInfo,
|
||||||
|
buildCalendarDiscoveryPropfindBody,
|
||||||
|
buildCalendarEventsReportBody,
|
||||||
|
dedupeEvents,
|
||||||
|
formatICalDate,
|
||||||
|
formatICalDateTimeUtc,
|
||||||
|
getCalDAVXmlHeaders,
|
||||||
|
getEventSortTimestamp,
|
||||||
|
parseCalendarsFromPROPFIND,
|
||||||
|
parseEventsFromCalDAV,
|
||||||
|
parseISODateOnlyLocalMidnight,
|
||||||
|
resolveCalendarSelectors,
|
||||||
|
stripEventInternalFields,
|
||||||
|
} from "../caldav.js";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export const calendarToolModule: ToolModule = {
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
name: "list_calendars",
|
||||||
|
description: "List available Nextcloud CalDAV calendars with href and supported components",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_calendar_events",
|
||||||
|
description: "Retrieve calendar events from Nextcloud. Can specify date range.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
startDate: {
|
||||||
|
type: "string",
|
||||||
|
description: "Start date in ISO format (YYYY-MM-DD). Defaults to today.",
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: "string",
|
||||||
|
description: "End date in ISO format (YYYY-MM-DD). Defaults to 30 days from start.",
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
type: "string",
|
||||||
|
description: "Calendar selector by display name or href path (optional)",
|
||||||
|
},
|
||||||
|
calendars: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "List of calendar selectors by display name or href path (optional)",
|
||||||
|
},
|
||||||
|
includeAllCalendars: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "When true and no calendar selectors are provided, query all VEVENT calendars",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum number of events to return",
|
||||||
|
default: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_calendar_event",
|
||||||
|
description: "Create a new calendar event in Nextcloud",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
summary: { type: "string", description: "Event title/summary" },
|
||||||
|
description: { type: "string", description: "Event description (optional)" },
|
||||||
|
allDay: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Create an all-day event. Use startDate/endDate when true.",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
startDateTime: {
|
||||||
|
type: "string",
|
||||||
|
description: "Start date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.",
|
||||||
|
},
|
||||||
|
endDateTime: {
|
||||||
|
type: "string",
|
||||||
|
description: "End date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.",
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
type: "string",
|
||||||
|
description: "All-day start date in YYYY-MM-DD (or YYYY/MM/DD). Required when allDay=true.",
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: "string",
|
||||||
|
description: "All-day inclusive end date in YYYY-MM-DD (or YYYY/MM/DD). Optional; defaults to startDate.",
|
||||||
|
},
|
||||||
|
reminderMinutesBefore: {
|
||||||
|
type: "number",
|
||||||
|
description: "Reminder trigger relative to event start, in minutes before start (optional).",
|
||||||
|
},
|
||||||
|
reminderDateTime: {
|
||||||
|
type: "string",
|
||||||
|
description: "Absolute reminder timestamp in ISO format (optional, mutually exclusive with reminderMinutesBefore).",
|
||||||
|
},
|
||||||
|
reminderDescription: {
|
||||||
|
type: "string",
|
||||||
|
description: "Reminder text shown to the user (optional).",
|
||||||
|
},
|
||||||
|
reminderAction: {
|
||||||
|
type: "string",
|
||||||
|
description: "Alarm action type (optional, defaults to DISPLAY).",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: "string",
|
||||||
|
description: "Event location (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async handler(name, args, client): Promise<ToolResponse> {
|
||||||
|
switch (name) {
|
||||||
|
case "list_calendars":
|
||||||
|
return listCalendars(client);
|
||||||
|
case "get_calendar_events":
|
||||||
|
return getCalendarEvents(args, client);
|
||||||
|
case "create_calendar_event":
|
||||||
|
return createCalendarEvent(args, client);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown calendar tool: ${name}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Implementation ---
|
||||||
|
|
||||||
|
async function listCalendars(client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const calendars = await discoverCalendars(client);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(calendars, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCalendarEvents(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const startDate = args.startDate || format(new Date(), "yyyy-MM-dd");
|
||||||
|
const endDate =
|
||||||
|
args.endDate ||
|
||||||
|
format(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), "yyyy-MM-dd");
|
||||||
|
const limit = args.limit || 50;
|
||||||
|
const includeAllCalendars = args.includeAllCalendars !== false;
|
||||||
|
const selectorList: string[] = [];
|
||||||
|
if (typeof args.calendar === "string" && args.calendar.trim()) {
|
||||||
|
selectorList.push(args.calendar.trim());
|
||||||
|
}
|
||||||
|
if (Array.isArray(args.calendars)) {
|
||||||
|
for (const selector of args.calendars) {
|
||||||
|
if (typeof selector === "string" && selector.trim()) {
|
||||||
|
selectorList.push(selector.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLocal = parseISODateOnlyLocalMidnight(startDate, "startDate");
|
||||||
|
const endLocal = parseISODateOnlyLocalMidnight(endDate, "endDate");
|
||||||
|
|
||||||
|
if (endLocal < startLocal) {
|
||||||
|
throw new Error("endDate must be on or after startDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const calendars = await discoverCalendars(client);
|
||||||
|
const veventCalendars = calendars.filter((calendar) =>
|
||||||
|
calendar.components.includes("VEVENT")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (veventCalendars.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify([], null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetCalendars: CalendarInfo[] = [];
|
||||||
|
if (selectorList.length > 0) {
|
||||||
|
targetCalendars = resolveCalendarSelectors(selectorList, calendars);
|
||||||
|
} else if (includeAllCalendars) {
|
||||||
|
targetCalendars = veventCalendars;
|
||||||
|
} else {
|
||||||
|
const personalCalendar = veventCalendars.find(
|
||||||
|
(calendar) => calendar.name.toLowerCase() === "personal"
|
||||||
|
);
|
||||||
|
targetCalendars = personalCalendar ? [personalCalendar] : [veventCalendars[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = buildCalendarEventsReportBody(startLocal, endLocal);
|
||||||
|
|
||||||
|
const mergedEvents: any[] = [];
|
||||||
|
const requestErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const calendar of targetCalendars) {
|
||||||
|
try {
|
||||||
|
debugLog(client, `Querying calendar href: ${calendar.href}`);
|
||||||
|
debugLog(client, `REPORT body for ${calendar.href}:\n${requestBody}`);
|
||||||
|
|
||||||
|
const response = await client.report(calendar.href, requestBody, "1");
|
||||||
|
|
||||||
|
debugLog(
|
||||||
|
client,
|
||||||
|
`REPORT response for ${calendar.href}: status=200 body-preview=${response.slice(0, 500)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = parseEventsFromCalDAV(
|
||||||
|
response,
|
||||||
|
calendar.href,
|
||||||
|
(message) => debugLog(client, message)
|
||||||
|
).map((event) => ({
|
||||||
|
...event,
|
||||||
|
calendarName: calendar.name,
|
||||||
|
calendarHref: calendar.href,
|
||||||
|
}));
|
||||||
|
mergedEvents.push(...events);
|
||||||
|
} catch (error: any) {
|
||||||
|
requestErrors.push(`${calendar.href}: ${error.message}`);
|
||||||
|
debugLog(client, `Calendar query failed for ${calendar.href}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedEvents.length === 0 && requestErrors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch calendar events from selected calendars: ${requestErrors.join("; ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = dedupeEvents(mergedEvents);
|
||||||
|
deduped.sort((a, b) => getEventSortTimestamp(a) - getEventSortTimestamp(b));
|
||||||
|
const events = deduped.slice(0, limit).map((event) => stripEventInternalFields(event));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to fetch calendar events: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCalendarEvent(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const {
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
allDay,
|
||||||
|
startDateTime,
|
||||||
|
endDateTime,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
reminderMinutesBefore,
|
||||||
|
reminderDateTime,
|
||||||
|
reminderDescription,
|
||||||
|
reminderAction,
|
||||||
|
} = args;
|
||||||
|
const uid = generateUID();
|
||||||
|
const isAllDay = allDay === true;
|
||||||
|
|
||||||
|
let dtStartLine = "";
|
||||||
|
let dtEndLine = "";
|
||||||
|
|
||||||
|
if (isAllDay) {
|
||||||
|
if (!startDate) {
|
||||||
|
throw new Error("startDate is required when allDay=true");
|
||||||
|
}
|
||||||
|
const start = parseISODateOnlyLocalMidnight(startDate, "startDate");
|
||||||
|
const endInclusive = parseISODateOnlyLocalMidnight(endDate || startDate, "endDate");
|
||||||
|
if (endInclusive < start) {
|
||||||
|
throw new Error("endDate must be on or after startDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endExclusive = new Date(endInclusive.getTime());
|
||||||
|
endExclusive.setDate(endExclusive.getDate() + 1);
|
||||||
|
|
||||||
|
dtStartLine = `DTSTART;VALUE=DATE:${formatICalDate(start)}`;
|
||||||
|
dtEndLine = `DTEND;VALUE=DATE:${formatICalDate(endExclusive)}`;
|
||||||
|
} else {
|
||||||
|
if (!startDateTime || !endDateTime) {
|
||||||
|
throw new Error("startDateTime and endDateTime are required for timed events");
|
||||||
|
}
|
||||||
|
const start = new Date(startDateTime);
|
||||||
|
const end = new Date(endDateTime);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
throw new Error("startDateTime/endDateTime must be valid ISO datetime values");
|
||||||
|
}
|
||||||
|
if (end < start) {
|
||||||
|
throw new Error("endDateTime must be on or after startDateTime");
|
||||||
|
}
|
||||||
|
|
||||||
|
dtStartLine = `DTSTART:${formatICalDateTimeUtc(start)}`;
|
||||||
|
dtEndLine = `DTEND:${formatICalDateTimeUtc(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reminderMinutesBefore !== undefined && reminderDateTime !== undefined) {
|
||||||
|
throw new Error("Use either reminderMinutesBefore or reminderDateTime, not both");
|
||||||
|
}
|
||||||
|
|
||||||
|
let alarmBlock = "";
|
||||||
|
if (reminderMinutesBefore !== undefined || reminderDateTime !== undefined) {
|
||||||
|
const action =
|
||||||
|
typeof reminderAction === "string" && reminderAction.trim()
|
||||||
|
? reminderAction.trim().toUpperCase()
|
||||||
|
: "DISPLAY";
|
||||||
|
const alarmDescription = escapeICalText(reminderDescription || "Reminder");
|
||||||
|
|
||||||
|
if (reminderMinutesBefore !== undefined) {
|
||||||
|
const minutes = Number(reminderMinutesBefore);
|
||||||
|
if (!Number.isFinite(minutes) || minutes < 0) {
|
||||||
|
throw new Error("reminderMinutesBefore must be a number >= 0");
|
||||||
|
}
|
||||||
|
const roundedMinutes = Math.floor(minutes);
|
||||||
|
alarmBlock = `\nBEGIN:VALARM
|
||||||
|
ACTION:${action}
|
||||||
|
TRIGGER:-PT${roundedMinutes}M
|
||||||
|
DESCRIPTION:${alarmDescription}
|
||||||
|
END:VALARM`;
|
||||||
|
} else if (reminderDateTime !== undefined) {
|
||||||
|
const reminderAt = new Date(reminderDateTime);
|
||||||
|
if (Number.isNaN(reminderAt.getTime())) {
|
||||||
|
throw new Error("reminderDateTime must be a valid ISO datetime value");
|
||||||
|
}
|
||||||
|
alarmBlock = `\nBEGIN:VALARM
|
||||||
|
ACTION:${action}
|
||||||
|
TRIGGER;VALUE=DATE-TIME:${formatICalDateTimeUtc(reminderAt)}
|
||||||
|
DESCRIPTION:${alarmDescription}
|
||||||
|
END:VALARM`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vevent = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nextcloud MCP Server//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:${uid}
|
||||||
|
SUMMARY:${escapeICalText(summary)}
|
||||||
|
${dtStartLine}
|
||||||
|
${dtEndLine}
|
||||||
|
CREATED:${formatICalDateTimeUtc(new Date())}`;
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
vevent += `\nDESCRIPTION:${escapeICalText(description)}`;
|
||||||
|
}
|
||||||
|
if (location) {
|
||||||
|
vevent += `\nLOCATION:${escapeICalText(location)}`;
|
||||||
|
}
|
||||||
|
vevent += alarmBlock;
|
||||||
|
|
||||||
|
vevent += `\nEND:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caldavPath = `/remote.php/dav/calendars/${client.username}/personal/${uid}.ics`;
|
||||||
|
|
||||||
|
await client.put(caldavPath, vevent, {
|
||||||
|
"Content-Type": "text/calendar",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Calendar event created successfully with UID: ${uid}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to create calendar event: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function generateUID(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeICalText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "\n")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/;/g, "\\;")
|
||||||
|
.replace(/,/g, "\\,");
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1";
|
||||||
|
|
||||||
|
function debugLog(client: NextcloudClient, message: string): void {
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.error(`[DEBUG_NEXTCLOUD_MCP] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverCalendars(client: NextcloudClient): Promise<CalendarInfo[]> {
|
||||||
|
const calendarsRoot = `/remote.php/dav/calendars/${client.username}/`;
|
||||||
|
const requestBody = buildCalendarDiscoveryPropfindBody();
|
||||||
|
|
||||||
|
debugLog(client, `PROPFIND calendars root: ${calendarsRoot}`);
|
||||||
|
|
||||||
|
const response = await client.propfind(calendarsRoot, requestBody, "1");
|
||||||
|
|
||||||
|
debugLog(
|
||||||
|
client,
|
||||||
|
`PROPFIND response status=200 body-preview=${response.slice(0, 500)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseCalendarsFromPROPFIND(response, calendarsRoot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { NextcloudClient } from "../client.js";
|
||||||
|
import { ToolModule } from "./index.js";
|
||||||
|
import { ToolResponse } from "../types.js";
|
||||||
|
|
||||||
|
export const emailToolModule: ToolModule = {
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
name: "get_emails",
|
||||||
|
description:
|
||||||
|
"Retrieve emails from Nextcloud Mail app. Returns recent emails from inbox.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
accountId: {
|
||||||
|
type: "number",
|
||||||
|
description: "Mail account ID (use 0 for default)",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum number of emails to return",
|
||||||
|
default: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async handler(name, args, client): Promise<ToolResponse> {
|
||||||
|
if (name === "get_emails") {
|
||||||
|
return getEmails(args, client);
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown email tool: ${name}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Implementation ---
|
||||||
|
|
||||||
|
async function getEmails(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const accountId = args.accountId || 0;
|
||||||
|
const limit = args.limit || 20;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get mailboxes first
|
||||||
|
const mailboxesResp = await client.get(
|
||||||
|
`/index.php/apps/mail/api/accounts/${accountId}/mailboxes`,
|
||||||
|
{
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const mailboxes = Array.isArray(mailboxesResp.data) ? mailboxesResp.data : [];
|
||||||
|
const inbox = mailboxes.find((mb: any) => mb.specialRole === "inbox");
|
||||||
|
|
||||||
|
if (!inbox) {
|
||||||
|
throw new Error("Inbox not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get messages from inbox
|
||||||
|
const messagesResp = await client.get(
|
||||||
|
`/index.php/apps/mail/api/messages?mailboxId=${inbox.id}`,
|
||||||
|
{
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emails = (Array.isArray(messagesResp.data) ? messagesResp.data : []).slice(0, limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(emails, null, 2) }],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to fetch emails: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { NextcloudClient } from "../client.js";
|
||||||
|
import { ToolModule } from "./index.js";
|
||||||
|
import { ToolResponse } from "../types.js";
|
||||||
|
|
||||||
|
// File management tools — scaffolding, will be populated in subsequent steps
|
||||||
|
export const filesToolModule: ToolModule = {
|
||||||
|
definitions: [],
|
||||||
|
|
||||||
|
async handler(_name, _args, _client): Promise<ToolResponse> {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "File tools not yet implemented." }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { NextcloudClient } from "../client.js";
|
||||||
|
import { ToolResponse } from "../types.js";
|
||||||
|
|
||||||
|
export interface ToolModule {
|
||||||
|
definitions: Tool[];
|
||||||
|
handler(name: string, args: any, client: NextcloudClient): Promise<ToolResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAllTools(modules: ToolModule[]): {
|
||||||
|
tools: Tool[];
|
||||||
|
handler: (name: string, args: any, client: NextcloudClient) => Promise<ToolResponse>;
|
||||||
|
} {
|
||||||
|
const tools: Tool[] = [];
|
||||||
|
const handlers = new Map<string, (args: any, client: NextcloudClient) => Promise<ToolResponse>>();
|
||||||
|
|
||||||
|
for (const mod of modules) {
|
||||||
|
for (const tool of mod.definitions) {
|
||||||
|
tools.push(tool);
|
||||||
|
handlers.set(tool.name, (args, client) => mod.handler(tool.name, args, client));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools,
|
||||||
|
async handler(name, args, client) {
|
||||||
|
const fn = handlers.get(name);
|
||||||
|
if (!fn) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Error: Unknown tool: ${name}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fn(args, client);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { NextcloudClient } from "../client.js";
|
||||||
|
import { ToolModule } from "./index.js";
|
||||||
|
import { ToolResponse } from "../types.js";
|
||||||
|
|
||||||
|
export const notesToolModule: ToolModule = {
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
name: "get_notes",
|
||||||
|
description: "Retrieve all notes from Nextcloud Notes app",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum number of notes to return",
|
||||||
|
default: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_note",
|
||||||
|
description: "Create a new note in Nextcloud Notes app",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Note title (first line)",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: "string",
|
||||||
|
description: "Note content (markdown supported)",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: "string",
|
||||||
|
description: "Note category/folder (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_note_content",
|
||||||
|
description: "Get the full content of a specific note by ID",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
noteId: {
|
||||||
|
type: "number",
|
||||||
|
description: "Note ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["noteId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async handler(name, args, client): Promise<ToolResponse> {
|
||||||
|
switch (name) {
|
||||||
|
case "get_notes":
|
||||||
|
return getNotes(args, client);
|
||||||
|
case "create_note":
|
||||||
|
return createNote(args, client);
|
||||||
|
case "get_note_content":
|
||||||
|
return getNoteContent(args, client);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown notes tool: ${name}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Implementation ---
|
||||||
|
|
||||||
|
async function getNotes(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const limit = args.limit || 50;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await client.get("/index.php/apps/notes/api/v1/notes", {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const notes = (Array.isArray(resp.data) ? resp.data : []).slice(0, limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to fetch notes: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNote(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const { title, content, category } = args;
|
||||||
|
const noteContent = title ? `${title}\n\n${content}` : content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: any = { content: noteContent };
|
||||||
|
if (category) payload.category = category;
|
||||||
|
|
||||||
|
const resp = await client.post(
|
||||||
|
"/index.php/apps/notes/api/v1/notes",
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Note created successfully with ID: ${resp.data.id}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to create note: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNoteContent(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const { noteId } = args;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await client.get(
|
||||||
|
`/index.php/apps/notes/api/v1/notes/${noteId}`,
|
||||||
|
{
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(resp.data, null, 2) }],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to fetch note: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { NextcloudClient } from "../client.js";
|
||||||
|
import { ToolModule } from "./index.js";
|
||||||
|
import { ToolResponse } from "../types.js";
|
||||||
|
import {
|
||||||
|
buildTasksReportBody,
|
||||||
|
formatICalDate,
|
||||||
|
formatICalDateTimeUtc,
|
||||||
|
getCalDAVXmlHeaders,
|
||||||
|
parseTasksFromCalDAV,
|
||||||
|
} from "../caldav.js";
|
||||||
|
|
||||||
|
export const tasksToolModule: ToolModule = {
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
name: "get_tasks",
|
||||||
|
description:
|
||||||
|
"Retrieve tasks from Nextcloud. Can filter by status (completed/open) and limit results.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["all", "open", "completed"],
|
||||||
|
description: "Filter tasks by status",
|
||||||
|
default: "all",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum number of tasks to return",
|
||||||
|
default: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_task",
|
||||||
|
description: "Create a new task in Nextcloud",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
summary: {
|
||||||
|
type: "string",
|
||||||
|
description: "Task title/summary",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: "string",
|
||||||
|
description: "Task description (optional)",
|
||||||
|
},
|
||||||
|
due: {
|
||||||
|
type: "string",
|
||||||
|
description: "Due date in ISO format (YYYY-MM-DD) (optional)",
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: "number",
|
||||||
|
description: "Priority (1-9, where 1 is highest) (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_task",
|
||||||
|
description: "Update an existing task (mark as complete, change summary, etc.)",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
taskId: {
|
||||||
|
type: "string",
|
||||||
|
description: "Task ID/UID",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
type: "string",
|
||||||
|
description: "New task title/summary (optional)",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"],
|
||||||
|
description: "New task status (optional)",
|
||||||
|
},
|
||||||
|
percentComplete: {
|
||||||
|
type: "number",
|
||||||
|
description: "Completion percentage 0-100 (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["taskId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async handler(name, args, client): Promise<ToolResponse> {
|
||||||
|
switch (name) {
|
||||||
|
case "get_tasks":
|
||||||
|
return getTasks(args, client);
|
||||||
|
case "create_task":
|
||||||
|
return createTask(args, client);
|
||||||
|
case "update_task":
|
||||||
|
return updateTask(args, client);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown tasks tool: ${name}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Implementation ---
|
||||||
|
|
||||||
|
async function getTasks(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const status = args.status || "all";
|
||||||
|
const limit = args.limit || 50;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caldavPath = `/remote.php/dav/calendars/${client.username}/tasks/`;
|
||||||
|
const requestBody = buildTasksReportBody();
|
||||||
|
|
||||||
|
const response = await client.report(caldavPath, requestBody, "1");
|
||||||
|
const tasks = parseTasksFromCalDAV(response, status, limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to fetch tasks: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const { summary, description, due, priority } = args;
|
||||||
|
const uid = generateUID();
|
||||||
|
|
||||||
|
let vtodo = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nextcloud MCP Server//EN
|
||||||
|
BEGIN:VTODO
|
||||||
|
UID:${uid}
|
||||||
|
SUMMARY:${summary}
|
||||||
|
STATUS:NEEDS-ACTION
|
||||||
|
CREATED:${formatICalDateTimeUtc(new Date())}`;
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
vtodo += `\nDESCRIPTION:${description}`;
|
||||||
|
}
|
||||||
|
if (due) {
|
||||||
|
vtodo += `\nDUE:${formatICalDate(new Date(due))}`;
|
||||||
|
}
|
||||||
|
if (priority) {
|
||||||
|
vtodo += `\nPRIORITY:${priority}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
vtodo += `\nEND:VTODO
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caldavPath = `/remote.php/dav/calendars/${client.username}/tasks/${uid}.ics`;
|
||||||
|
|
||||||
|
await client.put(caldavPath, vtodo, {
|
||||||
|
"Content-Type": "text/calendar",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Task created successfully with UID: ${uid}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to create task: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTask(args: any, client: NextcloudClient): Promise<ToolResponse> {
|
||||||
|
const { taskId, summary, status, percentComplete } = args;
|
||||||
|
const caldavPath = `/remote.php/dav/calendars/${client.username}/tasks/${taskId}.ics`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.get(caldavPath);
|
||||||
|
let vtodo = String(response.data ?? "");
|
||||||
|
|
||||||
|
if (summary) {
|
||||||
|
vtodo = vtodo.replace(/SUMMARY:.*/, `SUMMARY:${summary}`);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
vtodo = vtodo.replace(/STATUS:.*/, `STATUS:${status}`);
|
||||||
|
}
|
||||||
|
if (percentComplete !== undefined) {
|
||||||
|
if (vtodo.includes("PERCENT-COMPLETE:")) {
|
||||||
|
vtodo = vtodo.replace(
|
||||||
|
/PERCENT-COMPLETE:.*/,
|
||||||
|
`PERCENT-COMPLETE:${percentComplete}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
vtodo = vtodo.replace(
|
||||||
|
/END:VTODO/,
|
||||||
|
`PERCENT-COMPLETE:${percentComplete}\nEND:VTODO`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vtodo = vtodo.replace(
|
||||||
|
/LAST-MODIFIED:.*/,
|
||||||
|
`LAST-MODIFIED:${formatICalDateTimeUtc(new Date())}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.put(caldavPath, vtodo, {
|
||||||
|
"Content-Type": "text/calendar",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Task ${taskId} updated successfully` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to update task: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUID(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// === Core ===
|
||||||
|
|
||||||
|
export interface NextcloudConfig {
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string; // App password recommended
|
||||||
|
}
|
||||||
|
|
||||||
|
// === File Metadata ===
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
name: string; // filename
|
||||||
|
path: string; // path relativo alla root utente
|
||||||
|
type: "file" | "folder";
|
||||||
|
size?: number; // bytes (oc:size, include cartelle)
|
||||||
|
contentLength?: number; // bytes (d:getcontentlength, solo file)
|
||||||
|
mimeType?: string;
|
||||||
|
lastModified?: string; // ISO 8601
|
||||||
|
etag?: string;
|
||||||
|
fileId?: number; // oc:fileid
|
||||||
|
permissions?: string; // es. "RGDNVCK"
|
||||||
|
favorite?: boolean;
|
||||||
|
ownerId?: string;
|
||||||
|
ownerDisplayName?: string;
|
||||||
|
hasPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Cestino ===
|
||||||
|
|
||||||
|
export interface TrashedFile extends FileMetadata {
|
||||||
|
originalName: string; // nc:trashbin-filename
|
||||||
|
originalLocation: string; // nc:trashbin-original-location
|
||||||
|
deletionTime: string; // nc:trashbin-deletion-time (unix timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Versioni ===
|
||||||
|
|
||||||
|
export interface FileVersion {
|
||||||
|
name: string; // timestamp della versione
|
||||||
|
size: number;
|
||||||
|
lastModified: string;
|
||||||
|
etag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Quota ===
|
||||||
|
|
||||||
|
export interface QuotaInfo {
|
||||||
|
used: number; // bytes
|
||||||
|
available: number; // bytes (-1 = uncomputed, -2 = unknown, -3 = unlimited)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Upload ===
|
||||||
|
|
||||||
|
export interface BulkUploadResult {
|
||||||
|
path: string;
|
||||||
|
error: boolean;
|
||||||
|
etag?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkedUploadSession {
|
||||||
|
uploadId: string;
|
||||||
|
destination: string;
|
||||||
|
totalSize: number;
|
||||||
|
chunkSize: number;
|
||||||
|
totalChunks: number;
|
||||||
|
createdAt: number; // unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Search ===
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
path?: string;
|
||||||
|
query?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
minSize?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
modifiedAfter?: string;
|
||||||
|
modifiedBefore?: string;
|
||||||
|
favorite?: boolean;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tool ===
|
||||||
|
|
||||||
|
export interface ToolResponse {
|
||||||
|
[x: string]: unknown;
|
||||||
|
content: Array<{ type: "text"; text: string }>;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { NextcloudConfig } from "./types.js";
|
||||||
|
|
||||||
|
export function normalizePath(path: string): string {
|
||||||
|
if (!path) return "/";
|
||||||
|
let normalized = path.replace(/\\/g, "/");
|
||||||
|
if (!normalized.startsWith("/")) normalized = "/" + normalized;
|
||||||
|
normalized = normalized.replace(/\/+/g, "/");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDavPath(username: string, relativePath: string): string {
|
||||||
|
const normPath = normalizePath(relativePath);
|
||||||
|
return `/remote.php/dav/files/${username}${normPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDavUrl(config: NextcloudConfig, relativePath: string): string {
|
||||||
|
const base = config.url.replace(/\/$/, "");
|
||||||
|
return `${base}${buildDavPath(config.username, relativePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRelativePath(href: string, davBase: string): string {
|
||||||
|
const decoded = decodeURIComponent(href);
|
||||||
|
if (decoded.startsWith(davBase)) {
|
||||||
|
return decoded.slice(davBase.length) || "/";
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextMimeType(mimeType: string | undefined): boolean {
|
||||||
|
if (!mimeType) return false;
|
||||||
|
const textTypes = [
|
||||||
|
"text/",
|
||||||
|
"application/json",
|
||||||
|
"application/xml",
|
||||||
|
"application/javascript",
|
||||||
|
"application/typescript",
|
||||||
|
];
|
||||||
|
return textTypes.some((t) => mimeType.toLowerCase().startsWith(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
".txt": "text/plain",
|
||||||
|
".md": "text/markdown",
|
||||||
|
".html": "text/html",
|
||||||
|
".css": "text/css",
|
||||||
|
".js": "application/javascript",
|
||||||
|
".json": "application/json",
|
||||||
|
".xml": "application/xml",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".zip": "application/zip",
|
||||||
|
".tar": "application/x-tar",
|
||||||
|
".gz": "application/gzip",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".ics": "text/calendar",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function detectMimeType(filename: string): string {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
for (const [ext, mime] of Object.entries(MIME_MAP)) {
|
||||||
|
if (lower.endsWith(ext)) return mime;
|
||||||
|
}
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUUID(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeToolResponse(data: unknown, isError?: boolean): {
|
||||||
|
content: Array<{ type: "text"; text: string }>;
|
||||||
|
isError?: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||||
|
isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeErrorResponse(message: string): {
|
||||||
|
content: Array<{ type: "text"; text: string }>;
|
||||||
|
isError: true;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Error: ${message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
+385
@@ -0,0 +1,385 @@
|
|||||||
|
// WebDAV XML builders + parsers — scaffolding for Step 1
|
||||||
|
// Full implementations will be added in subsequent steps
|
||||||
|
|
||||||
|
import { SearchOptions, FileMetadata, TrashedFile, FileVersion, QuotaInfo } from "./types.js";
|
||||||
|
import { normalizePath } from "./utils.js";
|
||||||
|
|
||||||
|
// --- XML Builders (scaffolding) ---
|
||||||
|
|
||||||
|
export function buildPropfindBody(selectedProps?: string[]): string {
|
||||||
|
const props = selectedProps && selectedProps.length > 0
|
||||||
|
? selectedProps.map((p) => ` <${p} />`).join("\n")
|
||||||
|
: ` <d:displayname />
|
||||||
|
<d:getcontenttype />
|
||||||
|
<d:getlastmodified />
|
||||||
|
<d:getetag />
|
||||||
|
<d:getcontentlength />
|
||||||
|
<d:resourcetype />
|
||||||
|
<oc:fileid />
|
||||||
|
<oc:size />
|
||||||
|
<oc:permissions />
|
||||||
|
<oc:favorite />`;
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
${props}
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPropfindExtendedBody(): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname />
|
||||||
|
<d:getcontenttype />
|
||||||
|
<d:getlastmodified />
|
||||||
|
<d:getetag />
|
||||||
|
<d:getcontentlength />
|
||||||
|
<d:resourcetype />
|
||||||
|
<oc:fileid />
|
||||||
|
<oc:size />
|
||||||
|
<oc:permissions />
|
||||||
|
<oc:favorite />
|
||||||
|
<oc:owner-display-name />
|
||||||
|
<oc:owner-id />
|
||||||
|
<nc:has-preview />
|
||||||
|
<oc:checksums />
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrashbinPropfindBody(): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname />
|
||||||
|
<d:getcontenttype />
|
||||||
|
<d:getlastmodified />
|
||||||
|
<d:getetag />
|
||||||
|
<d:getcontentlength />
|
||||||
|
<d:resourcetype />
|
||||||
|
<oc:fileid />
|
||||||
|
<oc:size />
|
||||||
|
<oc:permissions />
|
||||||
|
<nc:trashbin-filename />
|
||||||
|
<nc:trashbin-original-location />
|
||||||
|
<nc:trashbin-deletion-time />
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVersionsPropfindBody(): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:getcontentlength />
|
||||||
|
<d:getlastmodified />
|
||||||
|
<d:getetag />
|
||||||
|
<oc:size />
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProppatchBody(namespace: string, property: string, value: string): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:set>
|
||||||
|
<d:prop>
|
||||||
|
<${namespace}:${property}>${value}</${namespace}:${property}>
|
||||||
|
</d:prop>
|
||||||
|
</d:set>
|
||||||
|
</d:propertyupdate>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFavoriteFilterBody(selectedProps?: string[]): string {
|
||||||
|
const props = selectedProps && selectedProps.length > 0
|
||||||
|
? selectedProps.map((p) => ` <${p} />`).join("\n")
|
||||||
|
: ` <d:displayname />
|
||||||
|
<d:getcontenttype />
|
||||||
|
<d:getlastmodified />
|
||||||
|
<d:getetag />
|
||||||
|
<d:getcontentlength />
|
||||||
|
<d:resourcetype />
|
||||||
|
<oc:fileid />
|
||||||
|
<oc:size />
|
||||||
|
<oc:permissions />
|
||||||
|
<oc:favorite />`;
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
${props}
|
||||||
|
</d:prop>
|
||||||
|
<oc:filter-rules>
|
||||||
|
<oc:favorite>1</oc:favorite>
|
||||||
|
</oc:filter-rules>
|
||||||
|
</oc:filter-files>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchRequest(options: SearchOptions): string {
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (options.query) {
|
||||||
|
filters.push(` <d:like>
|
||||||
|
<d:prop><d:displayname /></d:prop>
|
||||||
|
<d:literal>${escapeXml(options.query)}%</d:literal>
|
||||||
|
</d:like>`);
|
||||||
|
}
|
||||||
|
if (options.mimeType) {
|
||||||
|
filters.push(` <d:eq>
|
||||||
|
<d:prop><d:getcontenttype /></d:prop>
|
||||||
|
<d:literal>${escapeXml(options.mimeType)}</d:literal>
|
||||||
|
</d:eq>`);
|
||||||
|
}
|
||||||
|
if (options.minSize !== undefined) {
|
||||||
|
filters.push(` <d:gt>
|
||||||
|
<d:prop><oc:size /></d:prop>
|
||||||
|
<d:literal>${options.minSize}</d:literal>
|
||||||
|
</d:gt>`);
|
||||||
|
}
|
||||||
|
if (options.maxSize !== undefined) {
|
||||||
|
filters.push(` <d:lt>
|
||||||
|
<d:prop><oc:size /></d:prop>
|
||||||
|
<d:literal>${options.maxSize}</d:literal>
|
||||||
|
</d:lt>`);
|
||||||
|
}
|
||||||
|
if (options.modifiedAfter) {
|
||||||
|
filters.push(` <d:gt>
|
||||||
|
<d:prop><d:getlastmodified /></d:prop>
|
||||||
|
<d:literal>${escapeXml(options.modifiedAfter)}</d:literal>
|
||||||
|
</d:gt>`);
|
||||||
|
}
|
||||||
|
if (options.modifiedBefore) {
|
||||||
|
filters.push(` <d:lt>
|
||||||
|
<d:prop><d:getlastmodified /></d:prop>
|
||||||
|
<d:literal>${escapeXml(options.modifiedBefore)}</d:literal>
|
||||||
|
</d:lt>`);
|
||||||
|
}
|
||||||
|
if (options.favorite === true) {
|
||||||
|
filters.push(` <d:eq>
|
||||||
|
<d:prop><oc:favorite /></d:prop>
|
||||||
|
<d:literal>1</d:literal>
|
||||||
|
</d:eq>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let whereClause: string;
|
||||||
|
if (filters.length === 0) {
|
||||||
|
whereClause = ` <d:where>
|
||||||
|
<d:not><d:is-collection /></d:not>
|
||||||
|
</d:where>`;
|
||||||
|
} else if (filters.length === 1) {
|
||||||
|
whereClause = ` <d:where>\n${filters[0]}\n </d:where>`;
|
||||||
|
} else {
|
||||||
|
whereClause = ` <d:where>\n <d:and>\n${filters.join("\n")}\n </d:and>\n </d:where>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopePath = normalizePath(options.path || "/");
|
||||||
|
const scope = `/files/${escapeXml(scopePath)}`;
|
||||||
|
|
||||||
|
const orderby = options.sortBy
|
||||||
|
? ` <d:orderby>
|
||||||
|
<d:order>
|
||||||
|
<d:prop><d:${escapeXml(options.sortBy)} /></d:prop>
|
||||||
|
<d:${options.sortOrder === "desc" ? "descending" : "ascending"} />
|
||||||
|
</d:order>
|
||||||
|
</d:orderby>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const limit = options.limit !== undefined
|
||||||
|
? ` <d:limit>
|
||||||
|
<nresults>${options.limit}</nresults>
|
||||||
|
</d:limit>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:basicsearch>
|
||||||
|
<d:select>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname />
|
||||||
|
<d:getcontenttype />
|
||||||
|
<d:getlastmodified />
|
||||||
|
<d:getetag />
|
||||||
|
<d:getcontentlength />
|
||||||
|
<d:resourcetype />
|
||||||
|
<oc:fileid />
|
||||||
|
<oc:size />
|
||||||
|
<oc:permissions />
|
||||||
|
<oc:favorite />
|
||||||
|
</d:prop>
|
||||||
|
</d:select>
|
||||||
|
<d:from>
|
||||||
|
<d:scope>
|
||||||
|
<d:href>${scope}</d:href>
|
||||||
|
<d:depth>infinity</d:depth>
|
||||||
|
</d:scope>
|
||||||
|
</d:from>
|
||||||
|
${whereClause}
|
||||||
|
${orderby}
|
||||||
|
${limit}
|
||||||
|
</d:basicsearch>
|
||||||
|
</d:searchrequest>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- XML Parsers (regex-based, like caldav.ts) ---
|
||||||
|
|
||||||
|
function extractProperty(block: string, namespace: string, property: string): string | null {
|
||||||
|
const regex = new RegExp(`<(?:${namespace}:)?${property}[^>]*>([^<]*)</(?:${namespace}:)?${property}>`, "i");
|
||||||
|
const match = block.match(regex);
|
||||||
|
return match ? decodeXmlText(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNumericProperty(block: string, namespace: string, property: string): number | null {
|
||||||
|
const val = extractProperty(block, namespace, property);
|
||||||
|
if (val === null || val === "") return null;
|
||||||
|
const num = Number(val);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBooleanProperty(block: string, namespace: string, property: string): boolean {
|
||||||
|
const val = extractProperty(block, namespace, property);
|
||||||
|
return val === "1" || val?.toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCollection(block: string): boolean {
|
||||||
|
return /<(?:\w+:)?collection\b/i.test(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHref(block: string): string | null {
|
||||||
|
const match = block.match(/<(?:\w+:)?href[^>]*>([\s\S]*?)<\/(?:\w+:)?href>/);
|
||||||
|
return match ? decodeXmlText(match[1].trim()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePropfindFilesResponse(xml: string, basePath: string): FileMetadata[] {
|
||||||
|
const files: FileMetadata[] = [];
|
||||||
|
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
|
||||||
|
|
||||||
|
for (const match of responseMatches) {
|
||||||
|
const block = match[0];
|
||||||
|
const href = extractHref(block);
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
|
const name = extractProperty(block, "d", "displayname") || "";
|
||||||
|
const isFolder = hasCollection(block);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
name,
|
||||||
|
path: resolveRelativePathFromHref(href, basePath),
|
||||||
|
type: isFolder ? "folder" : "file",
|
||||||
|
size: extractNumericProperty(block, "oc", "size") ?? undefined,
|
||||||
|
contentLength: extractNumericProperty(block, "d", "getcontentlength") ?? undefined,
|
||||||
|
mimeType: extractProperty(block, "d", "getcontenttype") ?? undefined,
|
||||||
|
lastModified: extractProperty(block, "d", "getlastmodified") ?? undefined,
|
||||||
|
etag: extractProperty(block, "d", "getetag") ?? undefined,
|
||||||
|
fileId: extractNumericProperty(block, "oc", "fileid") ?? undefined,
|
||||||
|
permissions: extractProperty(block, "oc", "permissions") ?? undefined,
|
||||||
|
favorite: extractBooleanProperty(block, "oc", "favorite"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePropfindSingleFileResponse(xml: string, basePath: string): FileMetadata | null {
|
||||||
|
const files = parsePropfindFilesResponse(xml, basePath);
|
||||||
|
return files[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearchResponse(xml: string, basePath: string): FileMetadata[] {
|
||||||
|
return parsePropfindFilesResponse(xml, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrashbinResponse(xml: string): TrashedFile[] {
|
||||||
|
const files: TrashedFile[] = [];
|
||||||
|
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
|
||||||
|
|
||||||
|
for (const match of responseMatches) {
|
||||||
|
const block = match[0];
|
||||||
|
const href = extractHref(block);
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
|
const name = extractProperty(block, "d", "displayname") || "";
|
||||||
|
const isFolder = hasCollection(block);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
name,
|
||||||
|
path: href,
|
||||||
|
type: isFolder ? "folder" : "file",
|
||||||
|
size: extractNumericProperty(block, "oc", "size") ?? undefined,
|
||||||
|
contentLength: extractNumericProperty(block, "d", "getcontentlength") ?? undefined,
|
||||||
|
mimeType: extractProperty(block, "d", "getcontenttype") ?? undefined,
|
||||||
|
lastModified: extractProperty(block, "d", "getlastmodified") ?? undefined,
|
||||||
|
etag: extractProperty(block, "d", "getetag") ?? undefined,
|
||||||
|
fileId: extractNumericProperty(block, "oc", "fileid") ?? undefined,
|
||||||
|
permissions: extractProperty(block, "oc", "permissions") ?? undefined,
|
||||||
|
favorite: extractBooleanProperty(block, "oc", "favorite"),
|
||||||
|
originalName: extractProperty(block, "nc", "trashbin-filename") || name,
|
||||||
|
originalLocation: extractProperty(block, "nc", "trashbin-original-location") || "",
|
||||||
|
deletionTime: extractProperty(block, "nc", "trashbin-deletion-time") || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVersionsResponse(xml: string): FileVersion[] {
|
||||||
|
const versions: FileVersion[] = [];
|
||||||
|
const responseMatches = xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g);
|
||||||
|
|
||||||
|
for (const match of responseMatches) {
|
||||||
|
const block = match[0];
|
||||||
|
const href = extractHref(block);
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
|
const name = href.split("/").filter(Boolean).pop() || "";
|
||||||
|
const size = extractNumericProperty(block, "oc", "size") ?? extractNumericProperty(block, "d", "getcontentlength") ?? 0;
|
||||||
|
|
||||||
|
versions.push({
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
lastModified: extractProperty(block, "d", "getlastmodified") || "",
|
||||||
|
etag: extractProperty(block, "d", "getetag") ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuotaResponse(xml: string): QuotaInfo {
|
||||||
|
const used = extractNumericProperty(xml, "d", "quota-used-bytes") ?? 0;
|
||||||
|
const available = extractNumericProperty(xml, "d", "quota-available-bytes") ?? -1;
|
||||||
|
return { used, available };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function escapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
||||||
|
.replace(/&#([0-9]+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRelativePathFromHref(href: string, basePath: string): string {
|
||||||
|
const decoded = decodeURIComponent(href);
|
||||||
|
// basePath is like /remote.php/dav/files/user/
|
||||||
|
if (decoded.startsWith(basePath)) {
|
||||||
|
return decoded.slice(basePath.length) || "/";
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user