From 58f70cf3c5c4bba497da359158b2bb5815537dca Mon Sep 17 00:00:00 2001 From: Guillaume Duquesnay Date: Tue, 8 Jul 2025 19:25:10 +0700 Subject: [PATCH 1/5] feat: implement file move operation - Add move operation to PATCH endpoint for files - Use Operation: move, Target-Type: file, Target: path - Automatically creates parent directories if needed - Preserves all internal links using FileManager.renameFile() - Validates paths and prevents overwrites - Add tests for edge cases --- src/requestHandler.test.ts | 149 ++++++++++++++++++++++++++++++++++++- src/requestHandler.ts | 140 +++++++++++++++++++++++++++++----- 2 files changed, 265 insertions(+), 24 deletions(-) diff --git a/src/requestHandler.test.ts b/src/requestHandler.test.ts index 9638726..b08973b 100644 --- a/src/requestHandler.test.ts +++ b/src/requestHandler.test.ts @@ -1,16 +1,16 @@ import http from "http"; import request from "supertest"; -import RequestHandler from "./requestHandler"; -import { LocalRestApiSettings } from "./types"; -import { CERT_NAME } from "./constants"; import { App, - TFile, Command, HeadingCache, PluginManifest, + TFile, } from "../mocks/obsidian"; +import { CERT_NAME } from "./constants"; +import RequestHandler from "./requestHandler"; +import { LocalRestApiSettings } from "./types"; describe("requestHandler", () => { const API_KEY = "my api key"; @@ -740,6 +740,147 @@ describe("requestHandler", () => { ); }); }); + + describe("file move operation", () => { + test("successful move with Target-Type: file, Target: path", async () => { + const oldPath = "folder/file.md"; + const newPath = "another-folder/subfolder/file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + app.vault.adapter._exists = false; // destination doesn't exist + + // Mock fileManager and createFolder + (app as any).fileManager = { + renameFile: jest.fn().mockResolvedValue(undefined) + }; + app.vault.createFolder = jest.fn().mockResolvedValue(undefined); + + const response = await request(server) + .patch(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send(newPath) + .expect(200); + + expect(response.body.message).toEqual("File successfully moved"); + expect(response.body.oldPath).toEqual(oldPath); + expect(response.body.newPath).toEqual(newPath); + expect(app.vault.createFolder).toHaveBeenCalledWith("another-folder/subfolder"); + expect((app as any).fileManager.renameFile).toHaveBeenCalledWith(mockFile, newPath); + }); + + test("move fails with non-existent file", async () => { + // Mock file doesn't exist + app.vault._getAbstractFileByPath = null; + + await request(server) + .patch("/vault/non-existent.md") + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send("new-location/file.md") + .expect(404); + }); + + test("move fails when destination exists", async () => { + const oldPath = "folder/file.md"; + const newPath = "another-folder/existing-file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + app.vault.adapter._exists = true; // destination already exists + + const response = await request(server) + .patch(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send(newPath) + .expect(409); + + expect(response.body.message).toContain("Destination file already exists"); + }); + + test("move fails with empty new path", async () => { + const oldPath = "folder/file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .patch(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send("") + .expect(400); + + expect(response.body.message).toContain("New path is required"); + }); + + test("move fails when new path is a directory", async () => { + const oldPath = "folder/file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .patch(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send("new-folder/") + .expect(400); + + expect(response.body.message).toContain("New path must be a file path"); + }); + + test("move to root directory", async () => { + const oldPath = "deep/nested/folder/file.md"; + const newPath = "file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + app.vault.adapter._exists = false; // destination doesn't exist + + // Mock fileManager + (app as any).fileManager = { + renameFile: jest.fn().mockResolvedValue(undefined) + }; + app.vault.createFolder = jest.fn(); + + const response = await request(server) + .patch(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send(newPath) + .expect(200); + + expect(response.body.message).toEqual("File successfully moved"); + expect(app.vault.createFolder).not.toHaveBeenCalled(); // No need to create parent for root + expect((app as any).fileManager.renameFile).toHaveBeenCalledWith(mockFile, newPath); + }); + }); }); describe("commandGet", () => { diff --git a/src/requestHandler.ts b/src/requestHandler.ts index 106183b..9ee2cb6 100644 --- a/src/requestHandler.ts +++ b/src/requestHandler.ts @@ -1,3 +1,4 @@ +import forge from "node-forge"; import { apiVersion, App, @@ -9,18 +10,13 @@ import { } from "obsidian"; import periodicNotes from "obsidian-daily-notes-interface"; import { getAPI as getDataviewAPI } from "obsidian-dataview"; -import forge from "node-forge"; +import bodyParser from "body-parser"; +import cors from "cors"; import express from "express"; +import WildcardRegexp from "glob-to-regexp"; import http from "http"; -import cors from "cors"; -import mime from "mime-types"; -import bodyParser from "body-parser"; import jsonLogic from "json-logic-js"; -import responseTime from "response-time"; -import queryString from "query-string"; -import WildcardRegexp from "glob-to-regexp"; -import path from "path"; import { applyPatch, ContentType, @@ -29,7 +25,18 @@ import { PatchOperation, PatchTargetType, } from "markdown-patch"; +import mime from "mime-types"; +import path from "path"; +import queryString from "query-string"; +import responseTime from "response-time"; +import LocalRestApiPublicApi from "./api"; +import { + CERT_NAME, + ContentTypes, + ERROR_CODE_MESSAGES, + MaximumRequestSize, +} from "./constants"; import { CannedResponse, ErrorCode, @@ -48,13 +55,6 @@ import { getSplicePosition, toArrayBuffer, } from "./utils"; -import { - CERT_NAME, - ContentTypes, - ERROR_CODE_MESSAGES, - MaximumRequestSize, -} from "./constants"; -import LocalRestApiPublicApi from "./api"; // Import openapi.yaml as a string import openapiYaml from "../docs/openapi.yaml"; @@ -262,10 +262,10 @@ export default class RequestHandler { certificateInfo: this.requestIsAuthenticated(req) && certificate ? { - validityDays: getCertificateValidityDays(certificate), - regenerateRecommended: - !getCertificateIsUptoStandards(certificate), - } + validityDays: getCertificateValidityDays(certificate), + regenerateRecommended: + !getCertificateIsUptoStandards(certificate), + } : undefined, apiExtensions: this.requestIsAuthenticated(req) ? this.apiExtensions.map(({ manifest }) => manifest) @@ -509,6 +509,32 @@ export default class RequestHandler { }); return; } + + // Check for file-level operations BEFORE validation + if (targetType === "file") { + // Handle semantic file operations + + if (operation === "move") { + if (rawTarget !== "path") { + res.status(400).json({ + errorCode: 40005, + message: "move operation must use Target: path" + }); + return; + } + return this.handleMoveOperation(path, req, res); + } + } + + // Validate that file-specific operations are only used with file target type + if (operation === "move" && targetType !== "file") { + res.status(400).json({ + errorCode: 40006, + message: `Operation '${operation}' is only valid for Target-Type: file` + }); + return; + } + if (!["heading", "block", "frontmatter"].includes(targetType)) { this.returnCannedResponse(res, { errorCode: ErrorCode.InvalidTargetTypeHeader, @@ -521,7 +547,7 @@ export default class RequestHandler { }); return; } - if (!["append", "prepend", "replace"].includes(operation)) { + if (!["append", "prepend", "replace", "rename", "move"].includes(operation)) { this.returnCannedResponse(res, { errorCode: ErrorCode.InvalidOperation, }); @@ -674,6 +700,80 @@ export default class RequestHandler { return this._vaultDelete(path, req, res); } + async handleMoveOperation( + path: string, + req: express.Request, + res: express.Response + ): Promise { + + if (!path || path.endsWith("/")) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.RequestMethodValidOnlyForFiles, + }); + return; + } + + // For move operations, the new path should be in the request body + const newPath = typeof req.body === 'string' ? req.body.trim() : ''; + + if (!newPath) { + res.status(400).json({ + errorCode: 40001, + message: "New path is required in request body" + }); + return; + } + + // Validate new path + if (newPath.endsWith("/")) { + res.status(400).json({ + errorCode: 40002, + message: "New path must be a file path, not a directory" + }); + return; + } + + // Check if source file exists + const sourceFile = this.app.vault.getAbstractFileByPath(path); + if (!sourceFile || !(sourceFile instanceof TFile)) { + this.returnCannedResponse(res, { statusCode: 404 }); + return; + } + + // Check if destination already exists + const destExists = await this.app.vault.adapter.exists(newPath); + if (destExists) { + res.status(409).json({ + errorCode: 40901, + message: "Destination file already exists" + }); + return; + } + + try { + // Create parent directories if needed + const parentDir = newPath.substring(0, newPath.lastIndexOf('/')); + if (parentDir) { + await this.app.vault.createFolder(parentDir); + } + + // Use FileManager to move the file (preserves history and updates links) + // @ts-ignore - fileManager exists at runtime but not in type definitions + await this.app.fileManager.renameFile(sourceFile, newPath); + + res.status(200).json({ + message: "File successfully moved", + oldPath: path, + newPath: newPath + }); + } catch (error) { + res.status(500).json({ + errorCode: 50001, + message: `Failed to move file: ${error.message}` + }); + } + } + getPeriodicNoteInterface(): Record { return { daily: { From f97a004951e9dc74ceb5c93ef92415bfa242c114 Mon Sep 17 00:00:00 2001 From: Guillaume Duquesnay Date: Tue, 8 Jul 2025 19:46:00 +0700 Subject: [PATCH 2/5] refactor: simplify PATCH endpoint validation logic - Add new error codes for clearer validation messages - Use consistent returnCannedResponse pattern throughout - Reorder validation checks for better flow - Separate file operations from applyPatch operations early - Maintain upstream coding style and patterns --- src/constants.ts | 4 ++++ src/requestHandler.ts | 23 +++++++++-------------- src/types.ts | 2 ++ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 0551126..f40e1b2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -45,6 +45,10 @@ export const ERROR_CODE_MESSAGES: Record = { [ErrorCode.InvalidSearch]: "The search query you provided is not valid.", [ErrorCode.ErrorPreparingSimpleSearch]: "Error encountered while calling Obsidian `prepareSimpleSearch` API.", + [ErrorCode.InvalidMoveTarget]: + "For move operations, Target must be 'path'.", + [ErrorCode.InvalidOperationForTargetType]: + "This operation is not valid for the specified target type.", }; export enum ContentTypes { diff --git a/src/requestHandler.ts b/src/requestHandler.ts index 9ee2cb6..24c376a 100644 --- a/src/requestHandler.ts +++ b/src/requestHandler.ts @@ -516,9 +516,8 @@ export default class RequestHandler { if (operation === "move") { if (rawTarget !== "path") { - res.status(400).json({ - errorCode: 40005, - message: "move operation must use Target: path" + this.returnCannedResponse(res, { + errorCode: ErrorCode.InvalidMoveTarget, }); return; } @@ -526,28 +525,24 @@ export default class RequestHandler { } } - // Validate that file-specific operations are only used with file target type + // Validate file-only operations aren't used with other target types if (operation === "move" && targetType !== "file") { - res.status(400).json({ - errorCode: 40006, - message: `Operation '${operation}' is only valid for Target-Type: file` + this.returnCannedResponse(res, { + errorCode: ErrorCode.InvalidOperationForTargetType, }); return; } + // Only these target types continue to applyPatch if (!["heading", "block", "frontmatter"].includes(targetType)) { this.returnCannedResponse(res, { errorCode: ErrorCode.InvalidTargetTypeHeader, }); return; } - if (!operation) { - this.returnCannedResponse(res, { - errorCode: ErrorCode.MissingOperation, - }); - return; - } - if (!["append", "prepend", "replace", "rename", "move"].includes(operation)) { + + // Validate operations for applyPatch target types + if (!["append", "prepend", "replace"].includes(operation)) { this.returnCannedResponse(res, { errorCode: ErrorCode.InvalidOperation, }); diff --git a/src/types.ts b/src/types.ts index f6e035d..95e7ac1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,8 @@ export enum ErrorCode { PeriodDoesNotExist = 40460, PeriodicNoteDoesNotExist = 40461, RequestMethodValidOnlyForFiles = 40510, + InvalidMoveTarget = 40005, + InvalidOperationForTargetType = 40006, ErrorPreparingSimpleSearch = 50010, } From 7fbaf0ac4ec81d2636b0a8b36a2ce3e0866d0cc4 Mon Sep 17 00:00:00 2001 From: Guillaume Duquesnay Date: Tue, 8 Jul 2025 21:32:50 +0700 Subject: [PATCH 3/5] feat: add path validation security and consistent error handling to file move - Add path traversal protection to prevent access outside vault - Use returnCannedResponse consistently throughout handleMoveOperation - Add proper error codes to ErrorCode enum and ERROR_CODE_MESSAGES - Validate paths early to prevent directory path destinations - Check parent directory existence before creating - Add comprehensive security tests for path traversal attempts - Normalize paths to handle backslashes and multiple slashes --- src/constants.ts | 10 +++++++++ src/requestHandler.test.ts | 44 ++++++++++++++++++++++++++++++++++++++ src/requestHandler.ts | 40 ++++++++++++++++++++-------------- src/types.ts | 5 +++++ 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index f40e1b2..2951401 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -49,6 +49,16 @@ export const ERROR_CODE_MESSAGES: Record = { "For move operations, Target must be 'path'.", [ErrorCode.InvalidOperationForTargetType]: "This operation is not valid for the specified target type.", + [ErrorCode.MissingNewPath]: + "New path is required in request body.", + [ErrorCode.InvalidNewPath]: + "New path must be a file path, not a directory.", + [ErrorCode.PathTraversalNotAllowed]: + "Path traversal is not allowed. Paths must be relative and within the vault.", + [ErrorCode.DestinationAlreadyExists]: + "Destination file already exists.", + [ErrorCode.FileOperationFailed]: + "File operation failed. Check the error message for details.", }; export enum ContentTypes { diff --git a/src/requestHandler.test.ts b/src/requestHandler.test.ts index b08973b..a1c5e51 100644 --- a/src/requestHandler.test.ts +++ b/src/requestHandler.test.ts @@ -880,6 +880,50 @@ describe("requestHandler", () => { expect(app.vault.createFolder).not.toHaveBeenCalled(); // No need to create parent for root expect((app as any).fileManager.renameFile).toHaveBeenCalledWith(mockFile, newPath); }); + + test("move fails with path traversal attempt", async () => { + const oldPath = "folder/file.md"; + const maliciousPath = "../../../etc/passwd"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .patch(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send(maliciousPath) + .expect(400); + + expect(response.body.errorCode).toEqual(40003); + expect(response.body.message).toContain("Path traversal is not allowed"); + }); + + test("move fails with absolute path", async () => { + const oldPath = "folder/file.md"; + const absolutePath = "/etc/passwd"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .patch(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Content-Type", "text/plain") + .set("Operation", "move") + .set("Target-Type", "file") + .set("Target", "path") + .send(absolutePath) + .expect(400); + + expect(response.body.errorCode).toEqual(40003); + expect(response.body.message).toContain("Path traversal is not allowed"); + }); }); }); diff --git a/src/requestHandler.ts b/src/requestHandler.ts index 24c376a..9f2c58e 100644 --- a/src/requestHandler.ts +++ b/src/requestHandler.ts @@ -709,25 +709,34 @@ export default class RequestHandler { } // For move operations, the new path should be in the request body - const newPath = typeof req.body === 'string' ? req.body.trim() : ''; + const rawNewPath = typeof req.body === 'string' ? req.body.trim() : ''; - if (!newPath) { - res.status(400).json({ - errorCode: 40001, - message: "New path is required in request body" + if (!rawNewPath) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.MissingNewPath, + }); + return; + } + + // Check for path traversal attempts + if (rawNewPath.includes('..') || rawNewPath.startsWith('/')) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.PathTraversalNotAllowed, }); return; } - // Validate new path - if (newPath.endsWith("/")) { - res.status(400).json({ - errorCode: 40002, - message: "New path must be a file path, not a directory" + // Validate new path is not a directory + if (rawNewPath.endsWith("/")) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.InvalidNewPath, }); return; } + // Normalize the new path + const newPath = rawNewPath.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/|\/$/g, ''); + // Check if source file exists const sourceFile = this.app.vault.getAbstractFileByPath(path); if (!sourceFile || !(sourceFile instanceof TFile)) { @@ -738,9 +747,8 @@ export default class RequestHandler { // Check if destination already exists const destExists = await this.app.vault.adapter.exists(newPath); if (destExists) { - res.status(409).json({ - errorCode: 40901, - message: "Destination file already exists" + this.returnCannedResponse(res, { + errorCode: ErrorCode.DestinationAlreadyExists, }); return; } @@ -748,7 +756,7 @@ export default class RequestHandler { try { // Create parent directories if needed const parentDir = newPath.substring(0, newPath.lastIndexOf('/')); - if (parentDir) { + if (parentDir && !await this.app.vault.adapter.exists(parentDir)) { await this.app.vault.createFolder(parentDir); } @@ -762,8 +770,8 @@ export default class RequestHandler { newPath: newPath }); } catch (error) { - res.status(500).json({ - errorCode: 50001, + this.returnCannedResponse(res, { + errorCode: ErrorCode.FileOperationFailed, message: `Failed to move file: ${error.message}` }); } diff --git a/src/types.ts b/src/types.ts index 95e7ac1..c14c900 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,11 @@ export enum ErrorCode { RequestMethodValidOnlyForFiles = 40510, InvalidMoveTarget = 40005, InvalidOperationForTargetType = 40006, + MissingNewPath = 40001, + InvalidNewPath = 40002, + PathTraversalNotAllowed = 40003, + DestinationAlreadyExists = 40901, + FileOperationFailed = 50001, ErrorPreparingSimpleSearch = 50010, } From 3f6b44fbd0edad5359ce0e660d5a93abbe2077eb Mon Sep 17 00:00:00 2001 From: Guillaume Duquesnay Date: Tue, 8 Jul 2025 19:25:20 +0700 Subject: [PATCH 4/5] docs: Add OpenAPI documentation for file move operation - Add move operation to PATCH endpoint enum - Update Target parameter description for move operations - Include example for file move functionality - Documents Operation: move, Target-Type: file usage --- docs/openapi.yaml | 426 +++++++++++++++++++++---------------- docs/src/lib/patch.jsonnet | 15 ++ 2 files changed, 259 insertions(+), 182 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e6d80d9..e4a21b0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -54,9 +54,9 @@ components: info: description: | You can use this interface for trying out your Local REST API in Obsidian. - + Before trying the below tools, you will want to make sure you press the "Authorize" button below and provide the API Key you are shown when you open the "Local REST API" section of your Obsidian settings. All requests to the API require a valid API Key; so you won't get very far without doing that. - + When using this tool you may see browser security warnings due to your browser not trusting the self-signed certificate the plugin will generate on its first run. If you do, you can make those errors disappear by adding the certificate as a "Trusted Certificate" in your browser or operating system's settings. title: "Local REST API for Obsidian" version: "1.0" @@ -66,7 +66,7 @@ paths: get: description: | Returns basic details about the server as well as your authentication status. - + This is the only API request that does *not* require authentication. responses: "200": @@ -124,7 +124,7 @@ paths: get: description: | Returns the content of the currently active file in Obsidian. - + If you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details. parameters: [] responses: @@ -137,7 +137,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -150,16 +150,16 @@ paths: patch: description: | Inserts content into the currently-open note relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -171,113 +171,125 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - + ## Append Content Below a Heading - + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Append Content to a Block Reference - + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Add a Row to a Table Referenced by a Block Reference - + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). + + ## File Operations + + ### Moving a File + + To move a file to a new path, use: + - `Operation`: `move` + - `Target-Type`: `file` + - `Target`: `new/path/to/file.md` + - Request body: empty + + File rename and move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -288,6 +300,7 @@ paths: - "append" - "prepend" - "replace" + - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -309,6 +322,9 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. + + For file operations: + - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -334,7 +350,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -368,7 +384,7 @@ paths: post: description: | Appends content to the end of the currently-open note. - + If you would like to insert text relative to a particular heading instead of appending to the end of the file, see 'patch'. parameters: [] requestBody: @@ -377,7 +393,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -413,7 +429,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -596,7 +612,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -609,16 +625,16 @@ paths: patch: description: | Inserts content into the current periodic note for the specified period relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -630,113 +646,125 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - + ## Append Content Below a Heading - + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Append Content to a Block Reference - + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Add a Row to a Table Referenced by a Block Reference - + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). + + ## File Operations + + ### Moving a File + + To move a file to a new path, use: + - `Operation`: `move` + - `Target-Type`: `file` + - `Target`: `new/path/to/file.md` + - Request body: empty + + File move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -747,6 +775,7 @@ paths: - "append" - "prepend" - "replace" + - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -768,6 +797,9 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. + + For file operations: + - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -806,7 +838,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -860,7 +892,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -909,7 +941,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -1034,7 +1066,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -1047,16 +1079,16 @@ paths: patch: description: | Inserts content into a periodic note relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -1068,113 +1100,125 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - + ## Append Content Below a Heading - + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Append Content to a Block Reference - + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Add a Row to a Table Referenced by a Block Reference - + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). + + ## File Operations + + ### Moving a File + + To move a file to a new path, use: + - `Operation`: `move` + - `Target-Type`: `file` + - `Target`: `new/path/to/file.md` + - Request body: empty + + File move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -1185,6 +1229,7 @@ paths: - "append" - "prepend" - "replace" + - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -1206,6 +1251,9 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. + + For file operations: + - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -1262,7 +1310,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -1334,7 +1382,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -1401,7 +1449,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -1431,28 +1479,28 @@ paths: post: description: | Evaluates a provided query against each file in your vault. - + This endpoint supports multiple query formats. Your query should be specified in your request's body, and will be interpreted according to the `Content-type` header you specify from the below options.Additional query formats may be added in the future. - + # Dataview DQL (`application/vnd.olrapi.dataview.dql+txt`) - + Accepts a `TABLE`-type Dataview query as a text string. See [Dataview](https://blacksmithgu.github.io/obsidian-dataview/query/queries/)'s query documentation for information on how to construct a query. - + # JsonLogic (`application/vnd.olrapi.jsonlogic+json`) - + Accepts a JsonLogic query specified as JSON. See [JsonLogic](https://jsonlogic.com/operations.html)'s documentation for information about the base set of operators available, but in addition to those operators the following operators are available: - + - `glob: [PATTERN, VALUE]`: Returns `true` if a string matches a glob pattern. E.g.: `{"glob": ["*.foo", "bar.foo"]}` is `true` and `{"glob": ["*.bar", "bar.foo"]}` is `false`. - `regexp: [PATTERN, VALUE]`: Returns `true` if a string matches a regular expression. E.g.: `{"regexp": [".*\.foo", "bar.foo"]` is `true` and `{"regexp": [".*\.bar", "bar.foo"]}` is `false`. - + Returns only non-falsy results. "Non-falsy" here treats the following values as "falsy": - + - `false` - `null` or `undefined` - `0` - `[]` - `{}` - + Files are represented as an object having the schema described in the Schema named 'NoteJson' at the bottom of this page. Understanding the shape of a JSON object from a schema can be @@ -1607,7 +1655,7 @@ paths: get: description: | Lists files in the root directory of your vault. - + Note: that this is exactly the same API endpoint as the below "List files that exist in the specified directory." and exists here only due to a quirk of this particular interactive tool. responses: "200": @@ -1669,7 +1717,7 @@ paths: get: description: | Returns the content of the file at the specified path in your vault should the file exist. - + If you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details. parameters: - description: | @@ -1690,7 +1738,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -1703,16 +1751,16 @@ paths: patch: description: | Inserts content into an existing note relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -1724,113 +1772,123 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - + ## Append Content Below a Heading - + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Append Content to a Block Reference - + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - + ## Add a Row to a Table Referenced by a Block Reference - + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). + + ## File Operations + + To move a file to a new path, use: + - `Operation`: `move` + - `Target-Type`: `file` + - `Target`: `new/path/to/file.md` + - Request body: empty + + File move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -1841,6 +1899,7 @@ paths: - "append" - "prepend" - "replace" + - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -1862,6 +1921,9 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. + + For file operations: + - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -1895,7 +1957,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -1929,7 +1991,7 @@ paths: post: description: | Appends content to the end of an existing note. If the specified file does not yet exist, it will be created as an empty file. - + If you would like to insert text relative to a particular heading, block reference, or frontmatter field instead of appending to the end of the file, see 'patch'. parameters: - description: | @@ -1946,7 +2008,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -1992,7 +2054,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -2023,7 +2085,7 @@ paths: parameters: - description: | Path to list files from (relative to your vault root). Note that empty directories will not be returned. - + Note: this particular interactive tool requires that you provide an argument for this field, but the API itself will allow you to list the root folder of your vault. If you would like to try listing content in the root of your vault using this interactive tool, use the above "List files that exist in the root of your vault" form above. in: "path" name: "pathToDirectory" diff --git a/docs/src/lib/patch.jsonnet b/docs/src/lib/patch.jsonnet index 93e35b6..13c560e 100644 --- a/docs/src/lib/patch.jsonnet +++ b/docs/src/lib/patch.jsonnet @@ -11,6 +11,7 @@ 'append', 'prepend', 'replace', + 'move', ], }, }, @@ -44,6 +45,8 @@ description: ||| Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. + For file operations: + - When Operation is 'move' and Target-Type is 'file': Target should be the new file path |||, required: true, schema: { @@ -274,5 +277,17 @@ interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). + + ## File Operations + + ### Moving a File + + To move a file to a new path, use: + - `Operation`: `move` + - `Target-Type`: `file` + - `Target`: `new/path/to/file.md` + - Request body: empty + + File rename and move operations preserve internal links within your vault. |||, } From 65640480ea956c9a29850ea240db9b4408e2dca5 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Sat, 4 Oct 2025 19:22:21 +0300 Subject: [PATCH 5/5] Implement a custom MOVE HTTP method --- docs/openapi.yaml | 519 +++++++++++++++++++------------------ docs/src/lib/move.jsonnet | 85 ++++++ docs/src/lib/patch.jsonnet | 15 -- docs/src/openapi.jsonnet | 26 +- src/constants.ts | 12 +- src/requestHandler.test.ts | 89 ++----- src/requestHandler.ts | 57 ++-- src/types.ts | 6 +- 8 files changed, 423 insertions(+), 386 deletions(-) create mode 100644 docs/src/lib/move.jsonnet diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e4a21b0..af45648 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -54,9 +54,9 @@ components: info: description: | You can use this interface for trying out your Local REST API in Obsidian. - + Before trying the below tools, you will want to make sure you press the "Authorize" button below and provide the API Key you are shown when you open the "Local REST API" section of your Obsidian settings. All requests to the API require a valid API Key; so you won't get very far without doing that. - + When using this tool you may see browser security warnings due to your browser not trusting the self-signed certificate the plugin will generate on its first run. If you do, you can make those errors disappear by adding the certificate as a "Trusted Certificate" in your browser or operating system's settings. title: "Local REST API for Obsidian" version: "1.0" @@ -66,7 +66,7 @@ paths: get: description: | Returns basic details about the server as well as your authentication status. - + This is the only API request that does *not* require authentication. responses: "200": @@ -124,7 +124,7 @@ paths: get: description: | Returns the content of the currently active file in Obsidian. - + If you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details. parameters: [] responses: @@ -137,7 +137,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -150,16 +150,16 @@ paths: patch: description: | Inserts content into the currently-open note relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -171,125 +171,113 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - - ## Append Content Below a Heading - + + ## Append, Prepend, or Replace Content Below a Heading + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Append Content to a Block Reference - + + ## Append, Prepend, or Replace Content to a Block Reference + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Add a Row to a Table Referenced by a Block Reference - + + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). - - ## File Operations - - ### Moving a File - - To move a file to a new path, use: - - `Operation`: `move` - - `Target-Type`: `file` - - `Target`: `new/path/to/file.md` - - Request body: empty - - File rename and move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -300,7 +288,6 @@ paths: - "append" - "prepend" - "replace" - - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -322,9 +309,6 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. - - For file operations: - - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -350,7 +334,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -384,7 +368,7 @@ paths: post: description: | Appends content to the end of the currently-open note. - + If you would like to insert text relative to a particular heading instead of appending to the end of the file, see 'patch'. parameters: [] requestBody: @@ -393,7 +377,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -429,7 +413,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -612,7 +596,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -625,16 +609,16 @@ paths: patch: description: | Inserts content into the current periodic note for the specified period relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -646,125 +630,113 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - - ## Append Content Below a Heading - + + ## Append, Prepend, or Replace Content Below a Heading + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Append Content to a Block Reference - + + ## Append, Prepend, or Replace Content to a Block Reference + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Add a Row to a Table Referenced by a Block Reference - + + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). - - ## File Operations - - ### Moving a File - - To move a file to a new path, use: - - `Operation`: `move` - - `Target-Type`: `file` - - `Target`: `new/path/to/file.md` - - Request body: empty - - File move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -775,7 +747,6 @@ paths: - "append" - "prepend" - "replace" - - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -797,9 +768,6 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. - - For file operations: - - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -838,7 +806,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -892,7 +860,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -941,7 +909,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -1066,7 +1034,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -1079,16 +1047,16 @@ paths: patch: description: | Inserts content into a periodic note relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -1100,125 +1068,113 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - - ## Append Content Below a Heading - + + ## Append, Prepend, or Replace Content Below a Heading + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Append Content to a Block Reference - + + ## Append, Prepend, or Replace Content to a Block Reference + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Add a Row to a Table Referenced by a Block Reference - + + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). - - ## File Operations - - ### Moving a File - - To move a file to a new path, use: - - `Operation`: `move` - - `Target-Type`: `file` - - `Target`: `new/path/to/file.md` - - Request body: empty - - File move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -1229,7 +1185,6 @@ paths: - "append" - "prepend" - "replace" - - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -1251,9 +1206,6 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. - - For file operations: - - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -1310,7 +1262,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -1382,7 +1334,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -1449,7 +1401,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -1479,28 +1431,28 @@ paths: post: description: | Evaluates a provided query against each file in your vault. - + This endpoint supports multiple query formats. Your query should be specified in your request's body, and will be interpreted according to the `Content-type` header you specify from the below options.Additional query formats may be added in the future. - + # Dataview DQL (`application/vnd.olrapi.dataview.dql+txt`) - + Accepts a `TABLE`-type Dataview query as a text string. See [Dataview](https://blacksmithgu.github.io/obsidian-dataview/query/queries/)'s query documentation for information on how to construct a query. - + # JsonLogic (`application/vnd.olrapi.jsonlogic+json`) - + Accepts a JsonLogic query specified as JSON. See [JsonLogic](https://jsonlogic.com/operations.html)'s documentation for information about the base set of operators available, but in addition to those operators the following operators are available: - + - `glob: [PATTERN, VALUE]`: Returns `true` if a string matches a glob pattern. E.g.: `{"glob": ["*.foo", "bar.foo"]}` is `true` and `{"glob": ["*.bar", "bar.foo"]}` is `false`. - `regexp: [PATTERN, VALUE]`: Returns `true` if a string matches a regular expression. E.g.: `{"regexp": [".*\.foo", "bar.foo"]` is `true` and `{"regexp": [".*\.bar", "bar.foo"]}` is `false`. - + Returns only non-falsy results. "Non-falsy" here treats the following values as "falsy": - + - `false` - `null` or `undefined` - `0` - `[]` - `{}` - + Files are represented as an object having the schema described in the Schema named 'NoteJson' at the bottom of this page. Understanding the shape of a JSON object from a schema can be @@ -1655,7 +1607,7 @@ paths: get: description: | Lists files in the root directory of your vault. - + Note: that this is exactly the same API endpoint as the below "List files that exist in the specified directory." and exists here only due to a quirk of this particular interactive tool. responses: "200": @@ -1716,8 +1668,10 @@ paths: - "Vault Files" get: description: | + **Note:** This path also supports the WebDAV-style `MOVE` method for moving files. Specify the destination path in a `Destination` header. The MOVE method is not displayed in Swagger UI due to OpenAPI spec limitations, but is fully functional. See the raw openapi.yaml for complete MOVE documentation. + Returns the content of the file at the specified path in your vault should the file exist. - + If you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details. parameters: - description: | @@ -1738,7 +1692,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Success" @@ -1748,19 +1702,86 @@ paths: Return the content of a single file in your vault. tags: - "Vault Files" + move: + description: | + Moves a file from its current location to a new location specified in the Destination header. This operation preserves file history and updates internal links. The destination path must be provided in the Destination header following WebDAV conventions. + parameters: + - description: | + The new path for the file (relative to your vault root). Path must not contain ".." or start with "/" for security reasons. + in: "header" + name: "Destination" + required: true + schema: + format: "path" + type: "string" + - description: | + Path to the relevant file (relative to your vault root). + in: "path" + name: "filename" + required: true + schema: + format: "path" + type: "string" + responses: + "201": + content: + application/json: + schema: + properties: + message: + example: "File successfully moved" + type: "string" + newPath: + example: "another-folder/file.md" + type: "string" + oldPath: + example: "folder/file.md" + type: "string" + type: "object" + description: "File successfully moved" + "400": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: | + Bad request - Missing Destination header, invalid destination path, or path traversal attempt. + "404": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: "Source file does not exist." + "405": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: | + Your path references a directory instead of a file; this request method is valid only for files. + "409": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: "Destination file already exists." + summary: | + Move a file to a new location in your vault. + tags: + - "Vault Files" patch: description: | Inserts content into an existing note relative to a heading, block refeerence, or frontmatter field within that document. - + Allows you to modify the content relative to a heading, block reference, or frontmatter field in your document. - + Note that this API was changed in Version 3.0 of this extension and the earlier PATCH API is now deprecated. Requests made using the previous version of this API will continue to work until Version 4.0 is released. See https://github.com/coddingtonbear/obsidian-local-rest-api/wiki/Changes-to-PATCH-requests-between-versions-2.0-and-3.0 for more details and migration instructions. - + # Examples - + All of the below examples assume you have a document that looks like this: - + ```markdown --- alpha: 1 @@ -1772,123 +1793,113 @@ paths: - one - two --- - + # Heading 1 - + This is the content for heading one - + Also references some [[#^484ef2]] - + ## Subheading 1:1 Content for Subheading 1:1 - + ### Subsubheading 1:1:1 - + ### Subsubheading 1:1:2 - + Testing how block references work for a table.[[#^2c7cfa]] Some content for Subsubheading 1:1:2 - + More random text. - + ^2d9b4a - + ## Subheading 1:2 - + Content for Subheading 1:2. - + some content with a block reference ^484ef2 - + ## Subheading 1:3 | City | Population | | ------------ | ---------- | | Seattle, WA | 8 | | Portland, OR | 4 | - + ^2c7cfa ``` - - ## Append Content Below a Heading - + + ## Append, Prepend, or Replace Content Below a Heading + If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: - + - `Operation`: `append` - `Target-Type`: `heading` - `Target`: `Heading 1::Subheading 1:1:1` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Append Content to a Block Reference - + + ## Append, Prepend, or Replace Content to a Block Reference + If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: - + - `Operation`: `append` - `Target-Type`: `block` - `Target`: `2d9b4a` - with the request body: `Hello` - + The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - - ## Add a Row to a Table Referenced by a Block Reference - + + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference + If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following headers: - + - `Operation`: `append` - `TargetType`: `block` - `Target`: `2c7cfa` - `Content-Type`: `application/json` - with the request body: `[["Chicago, IL", "16"]]` - + The use of a `Content-Type` of `application/json` allows the API to infer that member of your array represents rows and columns of your to append to the referenced table. You can of course just use a `Content-Type` of `text/markdown`, but in such a case you'll have to format your table row manually instead of letting the library figure it out for you. - + You also have the option of using `prepend` (in which case, your new row would be the first -- right below the table heading) or `replace` (in which case all rows except the table heading would be replaced by the new row(s) you supplied). - + ## Setting a Frontmatter Field - + If you wanted to set the frontmatter field `alpha` to `2`, you could send the following headers: - + - `Operation`: `replace` - `TargetType`: `frontmatter` - `Target`: `beep` - with the request body `2` - + If you're setting a frontmatter field that might not already exist you may want to use the `Create-Target-If-Missing` header so the new frontmatter field is created and set to your specified value if it doesn't already exist. - + You may find using a `Content-Type` of `application/json` to be particularly useful in the case of frontmatter since frontmatter fields' values are JSON data, and the API can be smarter about interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). - - ## File Operations - - To move a file to a new path, use: - - `Operation`: `move` - - `Target-Type`: `file` - - `Target`: `new/path/to/file.md` - - Request body: empty - - File move operations preserve internal links within your vault. parameters: - description: "Patch operation to perform" in: "header" @@ -1899,7 +1910,6 @@ paths: - "append" - "prepend" - "replace" - - "move" type: "string" - description: "Type of target to patch" in: "header" @@ -1921,9 +1931,6 @@ paths: - description: | Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. - - For file operations: - - When Operation is 'move' and Target-Type is 'file': Target should be the new file path in: "header" name: "Target" required: true @@ -1957,7 +1964,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to insert." @@ -1991,7 +1998,7 @@ paths: post: description: | Appends content to the end of an existing note. If the specified file does not yet exist, it will be created as an empty file. - + If you would like to insert text relative to a particular heading, block reference, or frontmatter field instead of appending to the end of the file, see 'patch'. parameters: - description: | @@ -2008,7 +2015,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content you would like to append." @@ -2054,7 +2061,7 @@ paths: schema: example: | # This is my document - + something else here type: "string" description: "Content of the file you would like to upload." @@ -2085,7 +2092,7 @@ paths: parameters: - description: | Path to list files from (relative to your vault root). Note that empty directories will not be returned. - + Note: this particular interactive tool requires that you provide an argument for this field, but the API itself will allow you to list the root folder of your vault. If you would like to try listing content in the root of your vault using this interactive tool, use the above "List files that exist in the root of your vault" form above. in: "path" name: "pathToDirectory" diff --git a/docs/src/lib/move.jsonnet b/docs/src/lib/move.jsonnet new file mode 100644 index 0000000..8aa9295 --- /dev/null +++ b/docs/src/lib/move.jsonnet @@ -0,0 +1,85 @@ +{ + tags: [ + 'Vault Files', + ], + summary: 'Move a file to a new location in your vault.\n', + description: 'Moves a file from its current location to a new location specified in the Destination header. This operation preserves file history and updates internal links. The destination path must be provided in the Destination header following WebDAV conventions.\n', + parameters: [ + { + name: 'Destination', + 'in': 'header', + description: 'The new path for the file (relative to your vault root). Path must not contain ".." or start with "/" for security reasons.\n', + required: true, + schema: { + type: 'string', + format: 'path', + }, + }, + ], + responses: { + '201': { + description: 'File successfully moved', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'File successfully moved', + }, + oldPath: { + type: 'string', + example: 'folder/file.md', + }, + newPath: { + type: 'string', + example: 'another-folder/file.md', + }, + }, + }, + }, + }, + }, + '400': { + description: 'Bad request - Missing Destination header, invalid destination path, or path traversal attempt.\n', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + '404': { + description: 'Source file does not exist.', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + '405': { + description: 'Your path references a directory instead of a file; this request method is valid only for files.\n', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + '409': { + description: 'Destination file already exists.', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + }, +} diff --git a/docs/src/lib/patch.jsonnet b/docs/src/lib/patch.jsonnet index 13c560e..93e35b6 100644 --- a/docs/src/lib/patch.jsonnet +++ b/docs/src/lib/patch.jsonnet @@ -11,7 +11,6 @@ 'append', 'prepend', 'replace', - 'move', ], }, }, @@ -45,8 +44,6 @@ description: ||| Target to patch; this value can be URL-Encoded and *must* be URL-Encoded if it includes non-ASCII characters. - For file operations: - - When Operation is 'move' and Target-Type is 'file': Target should be the new file path |||, required: true, schema: { @@ -277,17 +274,5 @@ interpreting yoru `prepend` or `append` requests if you specify your data as JSON (particularly when appending, for example, list items). - - ## File Operations - - ### Moving a File - - To move a file to a new path, use: - - `Operation`: `move` - - `Target-Type`: `file` - - `Target`: `new/path/to/file.md` - - Request body: empty - - File rename and move operations preserve internal links within your vault. |||, } diff --git a/docs/src/openapi.jsonnet b/docs/src/openapi.jsonnet index ebf6f47..90a80b2 100644 --- a/docs/src/openapi.jsonnet +++ b/docs/src/openapi.jsonnet @@ -1,14 +1,15 @@ -local Delete = import 'delete.jsonnet'; -local Get = import 'get.jsonnet'; -local Patch = import 'patch.jsonnet'; -local Post = import 'post.jsonnet'; -local Put = import 'put.jsonnet'; +local Delete = import 'lib/delete.jsonnet'; +local Get = import 'lib/get.jsonnet'; +local Move = import 'lib/move.jsonnet'; +local Patch = import 'lib/patch.jsonnet'; +local Post = import 'lib/post.jsonnet'; +local Put = import 'lib/put.jsonnet'; -local ParamDay = import 'day.param.jsonnet'; -local ParamMonth = import 'month.param.jsonnet'; -local ParamPath = import 'path.param.jsonnet'; -local ParamPeriod = import 'period.param.jsonnet'; -local ParamYear = import 'year.param.jsonnet'; +local ParamDay = import 'lib/day.param.jsonnet'; +local ParamMonth = import 'lib/month.param.jsonnet'; +local ParamPath = import 'lib/path.param.jsonnet'; +local ParamPeriod = import 'lib/period.param.jsonnet'; +local ParamYear = import 'lib/year.param.jsonnet'; std.manifestYamlDoc( @@ -166,7 +167,7 @@ std.manifestYamlDoc( 'Vault Files', ], summary: 'Return the content of a single file in your vault.\n', - description: 'Returns the content of the file at the specified path in your vault should the file exist.\n\nIf you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details.\n', + description: '**Note:** This path also supports the WebDAV-style `MOVE` method for moving files. Specify the destination path in a `Destination` header. The MOVE method is not displayed in Swagger UI due to OpenAPI spec limitations, but is fully functional. See the raw openapi.yaml for complete MOVE documentation.\n\nReturns the content of the file at the specified path in your vault should the file exist.\n\nIf you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details.\n', parameters+: [ParamPath], }, put: Put { @@ -193,6 +194,9 @@ std.manifestYamlDoc( description: 'Inserts content into an existing note relative to a heading, block refeerence, or frontmatter field within that document.\n\n' + Patch.description, parameters+: [ParamPath], }, + move: Move { + parameters: Move.parameters + [ParamPath], + }, delete: Delete { tags: [ 'Vault Files', diff --git a/src/constants.ts b/src/constants.ts index 2951401..a04e949 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -45,14 +45,10 @@ export const ERROR_CODE_MESSAGES: Record = { [ErrorCode.InvalidSearch]: "The search query you provided is not valid.", [ErrorCode.ErrorPreparingSimpleSearch]: "Error encountered while calling Obsidian `prepareSimpleSearch` API.", - [ErrorCode.InvalidMoveTarget]: - "For move operations, Target must be 'path'.", - [ErrorCode.InvalidOperationForTargetType]: - "This operation is not valid for the specified target type.", - [ErrorCode.MissingNewPath]: - "New path is required in request body.", - [ErrorCode.InvalidNewPath]: - "New path must be a file path, not a directory.", + [ErrorCode.MissingDestinationHeader]: + "Destination header is required for MOVE operations.", + [ErrorCode.InvalidDestinationPath]: + "Destination path must be a file path, not a directory.", [ErrorCode.PathTraversalNotAllowed]: "Path traversal is not allowed. Paths must be relative and within the vault.", [ErrorCode.DestinationAlreadyExists]: diff --git a/src/requestHandler.test.ts b/src/requestHandler.test.ts index a1c5e51..3a70775 100644 --- a/src/requestHandler.test.ts +++ b/src/requestHandler.test.ts @@ -742,7 +742,7 @@ describe("requestHandler", () => { }); describe("file move operation", () => { - test("successful move with Target-Type: file, Target: path", async () => { + test("successful move with Destination header", async () => { const oldPath = "folder/file.md"; const newPath = "another-folder/subfolder/file.md"; @@ -758,14 +758,10 @@ describe("requestHandler", () => { app.vault.createFolder = jest.fn().mockResolvedValue(undefined); const response = await request(server) - .patch(`/vault/${oldPath}`) + .move(`/vault/${oldPath}`) .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send(newPath) - .expect(200); + .set("Destination", newPath) + .expect(201); expect(response.body.message).toEqual("File successfully moved"); expect(response.body.oldPath).toEqual(oldPath); @@ -779,13 +775,9 @@ describe("requestHandler", () => { app.vault._getAbstractFileByPath = null; await request(server) - .patch("/vault/non-existent.md") + .move("/vault/non-existent.md") .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send("new-location/file.md") + .set("Destination", "new-location/file.md") .expect(404); }); @@ -799,19 +791,15 @@ describe("requestHandler", () => { app.vault.adapter._exists = true; // destination already exists const response = await request(server) - .patch(`/vault/${oldPath}`) + .move(`/vault/${oldPath}`) .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send(newPath) + .set("Destination", newPath) .expect(409); expect(response.body.message).toContain("Destination file already exists"); }); - test("move fails with empty new path", async () => { + test("move fails with missing Destination header", async () => { const oldPath = "folder/file.md"; // Mock file exists @@ -819,19 +807,14 @@ describe("requestHandler", () => { app.vault._getAbstractFileByPath = mockFile; const response = await request(server) - .patch(`/vault/${oldPath}`) + .move(`/vault/${oldPath}`) .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send("") .expect(400); - expect(response.body.message).toContain("New path is required"); + expect(response.body.message).toContain("Destination header is required"); }); - test("move fails when new path is a directory", async () => { + test("move fails when destination path is a directory", async () => { const oldPath = "folder/file.md"; // Mock file exists @@ -839,16 +822,12 @@ describe("requestHandler", () => { app.vault._getAbstractFileByPath = mockFile; const response = await request(server) - .patch(`/vault/${oldPath}`) + .move(`/vault/${oldPath}`) .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send("new-folder/") + .set("Destination", "new-folder/") .expect(400); - expect(response.body.message).toContain("New path must be a file path"); + expect(response.body.message).toContain("Destination path must be a file path"); }); test("move to root directory", async () => { @@ -867,14 +846,10 @@ describe("requestHandler", () => { app.vault.createFolder = jest.fn(); const response = await request(server) - .patch(`/vault/${oldPath}`) + .move(`/vault/${oldPath}`) .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send(newPath) - .expect(200); + .set("Destination", newPath) + .expect(201); expect(response.body.message).toEqual("File successfully moved"); expect(app.vault.createFolder).not.toHaveBeenCalled(); // No need to create parent for root @@ -884,21 +859,17 @@ describe("requestHandler", () => { test("move fails with path traversal attempt", async () => { const oldPath = "folder/file.md"; const maliciousPath = "../../../etc/passwd"; - + // Mock file exists const mockFile = new TFile(); app.vault._getAbstractFileByPath = mockFile; - + const response = await request(server) - .patch(`/vault/${oldPath}`) + .move(`/vault/${oldPath}`) .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send(maliciousPath) + .set("Destination", maliciousPath) .expect(400); - + expect(response.body.errorCode).toEqual(40003); expect(response.body.message).toContain("Path traversal is not allowed"); }); @@ -906,21 +877,17 @@ describe("requestHandler", () => { test("move fails with absolute path", async () => { const oldPath = "folder/file.md"; const absolutePath = "/etc/passwd"; - + // Mock file exists const mockFile = new TFile(); app.vault._getAbstractFileByPath = mockFile; - + const response = await request(server) - .patch(`/vault/${oldPath}`) + .move(`/vault/${oldPath}`) .set("Authorization", `Bearer ${API_KEY}`) - .set("Content-Type", "text/plain") - .set("Operation", "move") - .set("Target-Type", "file") - .set("Target", "path") - .send(absolutePath) + .set("Destination", absolutePath) .expect(400); - + expect(response.body.errorCode).toEqual(40003); expect(response.body.message).toContain("Path traversal is not allowed"); }); diff --git a/src/requestHandler.ts b/src/requestHandler.ts index 9f2c58e..4a4b1a1 100644 --- a/src/requestHandler.ts +++ b/src/requestHandler.ts @@ -510,30 +510,7 @@ export default class RequestHandler { return; } - // Check for file-level operations BEFORE validation - if (targetType === "file") { - // Handle semantic file operations - - if (operation === "move") { - if (rawTarget !== "path") { - this.returnCannedResponse(res, { - errorCode: ErrorCode.InvalidMoveTarget, - }); - return; - } - return this.handleMoveOperation(path, req, res); - } - } - - // Validate file-only operations aren't used with other target types - if (operation === "move" && targetType !== "file") { - this.returnCannedResponse(res, { - errorCode: ErrorCode.InvalidOperationForTargetType, - }); - return; - } - - // Only these target types continue to applyPatch + // Only these target types are valid for PATCH if (!["heading", "block", "frontmatter"].includes(targetType)) { this.returnCannedResponse(res, { errorCode: ErrorCode.InvalidTargetTypeHeader, @@ -695,7 +672,7 @@ export default class RequestHandler { return this._vaultDelete(path, req, res); } - async handleMoveOperation( + async _vaultMove( path: string, req: express.Request, res: express.Response @@ -708,16 +685,18 @@ export default class RequestHandler { return; } - // For move operations, the new path should be in the request body - const rawNewPath = typeof req.body === 'string' ? req.body.trim() : ''; + // For WebDAV-style MOVE, the new path should be in the Destination header + const rawDestination = req.get('Destination'); - if (!rawNewPath) { + if (!rawDestination) { this.returnCannedResponse(res, { - errorCode: ErrorCode.MissingNewPath, + errorCode: ErrorCode.MissingDestinationHeader, }); return; } + const rawNewPath = decodeURIComponent(rawDestination.trim()); + // Check for path traversal attempts if (rawNewPath.includes('..') || rawNewPath.startsWith('/')) { this.returnCannedResponse(res, { @@ -729,7 +708,7 @@ export default class RequestHandler { // Validate new path is not a directory if (rawNewPath.endsWith("/")) { this.returnCannedResponse(res, { - errorCode: ErrorCode.InvalidNewPath, + errorCode: ErrorCode.InvalidDestinationPath, }); return; } @@ -764,7 +743,7 @@ export default class RequestHandler { // @ts-ignore - fileManager exists at runtime but not in type definitions await this.app.fileManager.renameFile(sourceFile, newPath); - res.status(200).json({ + res.status(201).json({ message: "File successfully moved", oldPath: path, newPath: newPath @@ -777,6 +756,14 @@ export default class RequestHandler { } } + async vaultMove(req: express.Request, res: express.Response): Promise { + const path = decodeURIComponent( + req.path.slice(req.path.indexOf("/", 1) + 1) + ); + + return this._vaultMove(path, req, res); + } + getPeriodicNoteInterface(): Record { return { daily: { @@ -1371,6 +1358,14 @@ export default class RequestHandler { .post(this.vaultPost.bind(this)) .delete(this.vaultDelete.bind(this)); + // WebDAV-style MOVE method + this.api.route("/vault/*").all((req, res, next) => { + if (req.method === "MOVE") { + return this.vaultMove(req, res); + } + next(); + }); + this.api .route("/periodic/:period/") .get(this.periodicGet.bind(this)) diff --git a/src/types.ts b/src/types.ts index c14c900..ea50da1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,10 +23,8 @@ export enum ErrorCode { PeriodDoesNotExist = 40460, PeriodicNoteDoesNotExist = 40461, RequestMethodValidOnlyForFiles = 40510, - InvalidMoveTarget = 40005, - InvalidOperationForTargetType = 40006, - MissingNewPath = 40001, - InvalidNewPath = 40002, + MissingDestinationHeader = 40001, + InvalidDestinationPath = 40002, PathTraversalNotAllowed = 40003, DestinationAlreadyExists = 40901, FileOperationFailed = 50001,