Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@caido/sdk-backend": "^0.48.0",
"@caido/sdk-frontend": "0.48.0",
"@caido/sdk-backend": "^0.55.3",
"@caido/sdk-frontend": "0.55.3",
"shared": "workspace:*"
},
"dependencies": {
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getFileContent } from "./file";
import { createFolder, deleteFolder } from "./folder";
import { getLegacyNotes, migrateNote } from "./migration";
import {
appendToNote,
createNote,
deleteNote,
getNote,
Expand All @@ -17,12 +18,14 @@ import {
dismissReminder,
getReminders,
} from "./reminder";
import { getSavedItem } from "./savedItem";

export {
getTree,
getNote,
createNote,
updateNote,
appendToNote,
deleteNote,
createFolder,
deleteFolder,
Expand All @@ -36,4 +39,5 @@ export {
createReminder,
deleteReminder,
dismissReminder,
getSavedItem,
};
65 changes: 64 additions & 1 deletion packages/backend/src/api/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import * as fs from "fs";
import path from "path";

import type { SDK } from "caido:plugin";
import type { Folder, Note, NoteContent, Result, TreeNode } from "shared";
import type {
Folder,
Note,
NoteContent,
NoteContentItem,
Result,
TreeNode,
} from "shared";
import { error, ok } from "shared";

import {
appendToNoteSchema,
createNoteSchema,
deleteNoteSchema,
getNoteSchema,
Expand Down Expand Up @@ -205,6 +213,61 @@ export async function updateNote(
}
}

/**
* Appends a single block to a note's content. Reads the file fresh and
* appends to that, in one step — so two separate appends to the same
* note can't overwrite each other, regardless of what either caller had
* cached beforehand.
*/
export async function appendToNote(
sdk: SDK,
notePath: string,
block: NoteContentItem,
): Promise<Result<Note>> {
try {
appendToNoteSchema.parse({ path: notePath, block });

const projectIDResult = await ensureProjectDirectory(sdk);
if (projectIDResult.kind === "Error") {
return error(projectIDResult.error);
}

const projectID = projectIDResult.value;
const pathWithExtension = ensureJsonExtension(notePath);
const rootPath = getNoteRootPath(projectID);
const fullPath = path.join(rootPath, pathWithExtension);

if (!fileExists(fullPath)) {
return error(`Note not found at path: ${notePath}`);
}

const existingContent = readFile(fullPath);
const updatedContent: NoteContent = {
...existingContent,
content: [...(existingContent.content || []), block],
};

writeFile(fullPath, updatedContent);

const stats = fs.statSync(fullPath);
const name = getNameFromPath(notePath);

return ok({
path: pathWithExtension,
name,
type: "note",
content: updatedContent,
metadata: {
createdAt: stats.birthtime,
modifiedAt: stats.mtime,
},
});
} catch (err) {
sdk.console.error(`Error appending to note: ${err}`);
return error(err instanceof Error ? err.message : String(err));
}
}

/**
* Delete a note
*/
Expand Down
125 changes: 125 additions & 0 deletions packages/backend/src/api/savedItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { SDK } from "caido:plugin";
import type { ResolvedSavedItem, Result, SavedItem } from "shared";
import { error, ok } from "shared";

import { getSavedItemSchema } from "../schemas/savedItem";

const REQUEST_RAW_QUERY = `
query GetRequestRaw($id: ID!) {
request(id: $id) {
raw
}
}
`;

const RESPONSE_RAW_QUERY = `
query GetResponseRaw($id: ID!) {
response(id: $id) {
raw
}
}
`;

/**
* Resolves a saved item (read from the note's own JSON content) back to
* its live raw HTTP content. For "history"/"replay" items, only the ID
* is stored, so this always fetches fresh from Caido; `found: false` is
* returned if the original request/response no longer exists.
*/
export async function getSavedItem(
sdk: SDK,
item: SavedItem,
): Promise<Result<ResolvedSavedItem>> {
try {
getSavedItemSchema.parse({ item });

if (item.kind === "request") {
if (item.sourceKind === "draft") {
// Never sent, so there's no Request.id to fetch — the raw text
// and connection info were stored directly in the note.
if (
item.draftRaw === undefined ||
item.draftHost === undefined ||
item.draftPort === undefined ||
item.draftIsTls === undefined
) {
sdk.console.error("Saved draft is missing required draft fields");
return ok({ found: false });
}

return ok({
found: true,
kind: "request",
sourceKind: "draft",
raw: item.draftRaw,
draftConnection: {
host: item.draftHost,
port: item.draftPort,
isTls: item.draftIsTls,
},
replaySessionId: item.replaySessionId,
sessionLabel: item.sessionLabel,
label: item.label,
});
}

const response = await sdk.graphql.execute<{
request: { raw: string } | undefined;
}>(REQUEST_RAW_QUERY, { id: item.refId });

if (response.errors?.length) {
sdk.console.error(
`GraphQL error fetching request ${item.refId}: ${response.errors.map((e) => e.message).join(", ")}`,
);
}

if (!response.data?.request) {
return ok({ found: false });
}

return ok({
found: true,
kind: "request",
sourceKind: item.sourceKind,
raw: response.data.request.raw,
requestId: item.refId,
replaySessionId: item.replaySessionId,
sessionLabel: item.sessionLabel,
label: item.label,
});
}

if (!item.parentRequestId) {
// Defensive: a response saved before this field existed, or saved
// through some path that didn't capture it. Without the parent
// request ID we can't support replay for this item.
sdk.console.error("Saved response is missing its parent request ID");
}

const response = await sdk.graphql.execute<{
response: { raw: string } | undefined;
}>(RESPONSE_RAW_QUERY, { id: item.refId });

if (response.errors?.length) {
sdk.console.error(
`GraphQL error fetching response ${item.refId}: ${response.errors.map((e) => e.message).join(", ")}`,
);
}

if (!response.data?.response) {
return ok({ found: false });
}

return ok({
found: true,
kind: "response",
sourceKind: item.sourceKind,
raw: response.data.response.raw,
...(item.parentRequestId ? { requestId: item.parentRequestId } : {}),
label: item.label,
});
} catch (err) {
sdk.console.error(`Error resolving saved item: ${err}`);
return error(err instanceof Error ? err.message : String(err));
}
}
6 changes: 6 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DefineAPI, SDK } from "caido:plugin";

import {
appendToNote,
createFolder,
createNote,
createReminder,
Expand All @@ -13,6 +14,7 @@ import {
getLegacyNotes,
getNote,
getReminders,
getSavedItem,
getTree,
migrateNote,
moveItem,
Expand All @@ -31,6 +33,7 @@ export type API = DefineAPI<{
getNote: typeof getNote;
createNote: typeof createNote;
updateNote: typeof updateNote;
appendToNote: typeof appendToNote;
deleteNote: typeof deleteNote;
createFolder: typeof createFolder;
deleteFolder: typeof deleteFolder;
Expand All @@ -44,13 +47,15 @@ export type API = DefineAPI<{
createReminder: typeof createReminder;
deleteReminder: typeof deleteReminder;
dismissReminder: typeof dismissReminder;
getSavedItem: typeof getSavedItem;
}>;

export function init(sdk: SDK<API, BackendEvents>) {
sdk.api.register("getTree", getTree);
sdk.api.register("getNote", getNote);
sdk.api.register("createNote", createNote);
sdk.api.register("updateNote", updateNote);
sdk.api.register("appendToNote", appendToNote);
sdk.api.register("deleteNote", deleteNote);
sdk.api.register("createFolder", createFolder);
sdk.api.register("deleteFolder", deleteFolder);
Expand All @@ -64,6 +69,7 @@ export function init(sdk: SDK<API, BackendEvents>) {
sdk.api.register("createReminder", createReminder);
sdk.api.register("deleteReminder", deleteReminder);
sdk.api.register("dismissReminder", dismissReminder);
sdk.api.register("getSavedItem", getSavedItem);

sdk.events.onProjectChange((sdk, project) => {
sdk.api.send("notes++:projectChange", project?.getId());
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/schemas/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ export const updateNoteSchema = z.object({
updates: z.object({}).passthrough(),
});

export const noteContentItemSchema: z.ZodType<{
type: string;
content?: unknown[];
text?: string;
attrs?: Record<string, unknown>;
}> = z.object({
type: z.string().min(1),
content: z.array(z.any()).optional(),
text: z.string().optional(),
attrs: z.record(z.unknown()).optional(),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export const appendToNoteSchema = z.object({
path: z.string().min(1),
block: noteContentItemSchema,
});

export const deleteNoteSchema = z.object({
path: z.string().min(1),
});
Expand Down
36 changes: 36 additions & 0 deletions packages/backend/src/schemas/savedItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z } from "zod";

/**
* The kind of context a saved request/response was captured from.
* - "history": Search, HTTP History, or Sitemap - all share the same
* underlying Request/Response rows, so the saved ID is the canonical one.
* - "replay": captured from a Replay pane after the request was actually
* sent (it has a real, persisted ID by that point).
* - "draft": captured from a Replay pane *before* it was ever sent. There
* is no Request.id to point to yet, so the raw content itself is stored
* directly instead of a reference.
*/
export const savedItemSourceKindSchema = z.enum(["history", "replay", "draft"]);

/**
* Validates a `SavedItem` as it comes back out of a note's own JSON
* content — the note file is user-editable on disk, so these attrs are
* untrusted input.
*/
export const savedItemSchema = z.object({
kind: z.enum(["request", "response"]),
refId: z.string(),
parentRequestId: z.string().optional(),
sourceKind: savedItemSourceKindSchema,
replaySessionId: z.string().optional(),
sessionLabel: z.string().optional(),
draftRaw: z.string().optional(),
draftHost: z.string().optional(),
draftPort: z.number().int().min(1).max(65535).optional(),
draftIsTls: z.boolean().optional(),
label: z.string().optional(),
});

export const getSavedItemSchema = z.object({
item: savedItemSchema,
});
4 changes: 2 additions & 2 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"vue": "3.5.13"
},
"devDependencies": {
"@caido/sdk-backend": "^0.48.0",
"@caido/sdk-frontend": "^0.48.0",
"@caido/sdk-backend": "^0.55.3",
"@caido/sdk-frontend": "^0.55.3",
"backend": "workspace:*",
"shared": "workspace:*",
"vue-tsc": "2.2.10"
Expand Down
Loading