Unverified Commit 1c037ca4 authored by Hong Minhee (洪 民憙)'s avatar Hong Minhee (洪 民憙) Committed by GitHub
Browse files

Merge pull request #321 from sij411/feat/output

parents 5db6ad89 e3c60556
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -97,10 +97,15 @@ the versioning.
     -  Added `@fedify/nestjs` package.
     -  Added `FedifyModule` for integrating Fedify into NestJS applications.

  -  Added `-o`/`--output` option to `fedify lookup` command. This option allows
     users to save retrieved lookup results to specified path. 
     [[#261], [#321] by Jiwon Kwon]

[#168]: https://github.com/fedify-dev/fedify/issues/168
[#197]: https://github.com/fedify-dev/fedify/issues/197
[#248]: https://github.com/fedify-dev/fedify/issues/248
[#260]: https://github.com/fedify-dev/fedify/issues/260
[#261]: https://github.com/fedify-dev/fedify/issues/261
[#262]: https://github.com/fedify-dev/fedify/issues/262
[#263]: https://github.com/fedify-dev/fedify/issues/263
[#269]: https://github.com/fedify-dev/fedify/issues/269
@@ -113,6 +118,8 @@ the versioning.
[#300]: https://github.com/fedify-dev/fedify/pull/300
[#304]: https://github.com/fedify-dev/fedify/issues/304
[#309]: https://github.com/fedify-dev/fedify/pull/309
[#321]: https://github.com/fedify-dev/fedify/pull/321



Version 1.7.6

cli/lookup.test.ts

0 → 100644
+301 −0
Original line number Diff line number Diff line
import { Activity, Note } from "@fedify/fedify";
import { assertEquals, assertExists } from "@std/assert";
import { getContextLoader } from "./docloader.ts";
import { createFileStream, writeObjectToStream } from "./lookup.ts";

Deno.test("createFileStream - creates file stream with proper directory creation", async () => {
  const testDir = "./test_output";
  const testFile = `${testDir}/test.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  assertExists(stream);

  const stat = await Deno.stat(testDir);
  assertEquals(stat.isDirectory, true);

  stream.close();

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("createFileStream - works with absolute paths", async () => {
  const testDir = `${Deno.cwd()}/test_output_absolute`;
  const testFile = `${testDir}/test.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  assertExists(stream);

  const stat = await Deno.stat(testDir);
  assertEquals(stat.isDirectory, true);

  stream.close();

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("createFileStream - creates nested directories", async () => {
  const testDir = "./test_output_nested/deep/path";
  const testFile = `${testDir}/test.json`;

  try {
    await Deno.remove("./test_output_nested", { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  assertExists(stream);

  // Verify nested directories were created
  const stat = await Deno.stat(testDir);
  assertEquals(stat.isDirectory, true);
  stream.close();

  await Deno.remove("./test_output_nested", { recursive: true });
});

Deno.test("createFileStream - writes data correctly", async () => {
  const testDir = "./test_output_write";
  const testFile = `${testDir}/test.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  const writer = stream.getWriter();

  const testData = new TextEncoder().encode("Hello, World!");
  await writer.write(testData);
  await writer.close();

  const content = await Deno.readTextFile(testFile);
  assertEquals(content, "Hello, World!");

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("createFileStream - truncates existing file", async () => {
  const testDir = "./test_output_truncate";
  const testFile = `${testDir}/test.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  await Deno.mkdir(testDir, { recursive: true });
  await Deno.writeTextFile(testFile, "Old content");

  const stream = await createFileStream(testFile);
  const writer = stream.getWriter();

  const testData = new TextEncoder().encode("New content");
  await writer.write(testData);
  await writer.close();

  // Verify file was truncated and new content written
  const content = await Deno.readTextFile(testFile);
  assertEquals(content, "New content");

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes Note object with default options", {
  sanitizeResources: false,
}, async () => {
  const testDir = "./test_output_note";
  const testFile = `${testDir}/note.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Hello, fediverse!",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Hello, fediverse!"), true);
  assertEquals(content.includes("Note"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes Activity object in raw JSON-LD format", async () => {
  const testDir = "./test_output_activity";
  const testFile = `${testDir}/activity.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const activity = new Activity({
    id: new URL("https://example.com/activities/1"),
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
    raw: true,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(activity, options, contextLoader);

  // Verify file exists and contains JSON-LD
  const content = await Deno.readTextFile(testFile);

  assertExists(content);
  assertEquals(content.includes("@context"), true);
  assertEquals(content.includes("id"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes object in compact JSON-LD format", async () => {
  const testDir = "./test_output_compact";
  const testFile = `${testDir}/compact.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Test note",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
    compact: true,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  // Verify file exists and contains compacted JSON-LD
  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Test note"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes object in expanded JSON-LD format", async () => {
  const testDir = "./test_output_expand";
  const testFile = `${testDir}/expand.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Test note for expansion",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
    expand: true,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Test note for expansion"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes to stdout when no output file specified", async () => {
  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Test stdout note",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);
});

Deno.test("writeObjectToStream - handles empty content properly", async () => {
  const testDir = "./test_output_empty";
  const testFile = `${testDir}/empty.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Note"), true);

  await Deno.remove(testDir, { recursive: true });
});
+115 −20
Original line number Diff line number Diff line
@@ -15,15 +15,114 @@ import {
  traverseCollection,
} from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";
import { dirname, isAbsolute, resolve } from "@std/path";
import ora from "ora";
import { getContextLoader, getDocumentLoader } from "./docloader.ts";
import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts";
import { printJson } from "./utils.ts";

const logger = getLogger(["fedify", "cli", "lookup"]);

const sigSpec = new EnumType(["draft-cavage-http-signatures-12", "rfc9421"]);

interface CommandOptions {
  authorizedFetch?: boolean;
  firstKnock: "draft-cavage-http-signatures-12" | "rfc9421";
  traverse?: boolean;
  suppressErrors?: boolean;
  raw?: boolean;
  compact?: boolean;
  expand?: boolean;
  userAgent?: string;
  separator: string;
  output?: string;
}

export async function createFileStream(
  outputPath: string,
): Promise<WritableStream> {
  try {
    const filepath = isAbsolute(outputPath)
      ? outputPath
      : resolve(Deno.env.get("PWD") || Deno.cwd(), outputPath);

    const parentDir = dirname(filepath);
    await Deno.mkdir(parentDir, { recursive: true });

    const file = await Deno.open(filepath, {
      write: true,
      create: true,
      truncate: true,
    });

    return new WritableStream({
      write: (chunk) => file.write(chunk).then(() => {}),
      close: () => file.close(),
      abort: (reason) => {
        file.close();
        throw reason;
      },
    });
  } catch (err) {
    const spinner = ora({
      text: `Failed to write output to ${colors.red(outputPath)}.`,
      discardStdin: false,
    });
    spinner.fail();
    console.error(`Error: ${String(err)}`);

    if (err instanceof Deno.errors.PermissionDenied) {
      console.error(
        "Permission denied. Try running with proper permissions.",
      );
    } else if (err instanceof Deno.errors.NotFound) {
      console.error("Path does not exist or is invalid.");
    } else if (err instanceof Deno.errors.IsADirectory) {
      console.error("The specified path is a directory, not a file.");
    }
    Deno.exit(1);
  }
}

export async function writeObjectToStream(
  object: Object | Link,
  options: CommandOptions,
  contextLoader: DocumentLoader,
): Promise<void> {
  const stream = options.output
    ? await createFileStream(options.output)
    : Deno.stdout.writable;

  const writer = stream.getWriter();

  try {
    let content;

    if (options.raw) {
      content = await object.toJsonLd({ contextLoader });
    } else if (options.compact) {
      content = await object.toJsonLd({ format: "compact", contextLoader });
    } else if (options.expand) {
      content = await object.toJsonLd({ format: "expand", contextLoader });
    } else {
      content = object;
    }

    content = Deno.inspect(content, {
      colors: !(options.output),
    });

    const encoder = new TextEncoder();
    const bytes = encoder.encode(content + "\n");

    await writer.write(bytes);
  } finally {
    writer.releaseLock();
    if (options.output) {
      await stream.close();
    }
  }
}

export const command = new Command()
  .type("sig-spec", sigSpec)
  .arguments("<...urls:string>")
@@ -65,6 +164,10 @@ export const command = new Command()
      "collection items.",
    { default: "----" },
  )
  .option(
    "-o, --output <file>",
    "Specify the output file path.",
  )
  .action(async (options, ...urls: string[]) => {
    if (urls.length < 1) {
      console.error("At least one URL or actor handle must be provided.");
@@ -75,6 +178,7 @@ export const command = new Command()
      );
      Deno.exit(1);
    }

    const spinner = ora({
      text: `Looking up the ${
        options.traverse ? "collection" : urls.length > 1 ? "objects" : "object"
@@ -143,26 +247,11 @@ export const command = new Command()
        },
      );
    }

    spinner.text = `Looking up the ${
      options.traverse ? "collection" : urls.length > 1 ? "objects" : "object"
    }...`;

    async function printObject(object: Object | Link): Promise<void> {
      if (options.raw) {
        printJson(await object.toJsonLd({ contextLoader }));
      } else if (options.compact) {
        printJson(
          await object.toJsonLd({ format: "compact", contextLoader }),
        );
      } else if (options.expand) {
        printJson(
          await object.toJsonLd({ format: "expand", contextLoader }),
        );
      } else {
        console.log(object);
      }
    }

    if (options.traverse) {
      const url = urls[0];
      const collection = await lookupObject(url, {
@@ -198,8 +287,8 @@ export const command = new Command()
            suppressError: options.suppressErrors,
          })
        ) {
          if (i > 0) console.log(options.separator);
          printObject(item);
          if (!options.output && i > 0) console.log(options.separator);
          await writeObjectToStream(item, options, contextLoader);
          i++;
        }
      } catch (error) {
@@ -218,6 +307,7 @@ export const command = new Command()
        Deno.exit(1);
      }
      spinner.succeed("Successfully fetched all items in the collection.");

      await server?.close();
      Deno.exit(0);
    }
@@ -254,7 +344,7 @@ export const command = new Command()
          success = false;
        } else {
          spinner.succeed(`Fetched object: ${colors.green(url)}.`);
          printObject(object);
          await writeObjectToStream(object, options, contextLoader);
          if (i < urls.length - 1) {
            console.log(options.separator);
          }
@@ -274,6 +364,11 @@ export const command = new Command()
    if (!success) {
      Deno.exit(1);
    }
    if (success && options.output) {
      spinner.succeed(
        `Successfully wrote output to ${colors.green(options.output)}.`,
      );
    }
  });

// cSpell: ignore sigspec
+12 −0
Original line number Diff line number Diff line
@@ -811,6 +811,18 @@ It does not affect the output when looking up a single object.
> The separator is also used when looking up a collection object with the
> [`-t`/`--traverse`](#t-traverse-traverse-the-collection) option.

### `-o`/`--output`: Output file path

*This option is available since Fedify 1.8.0.*

You can specify the output file path to save lookup results, instead of 
printing results to stdout. For example, to save the retrieved information
about the specified objects to a given path, run the command below:

~~~~ sh
fedify lookup -o actors.json @fedify@hollo.social @hongminhee@fosstodon.org
~~~~


`fedify inbox`: Ephemeral inbox server
--------------------------------------