diff --git a/src/client/components/ArticleInfo.tsx b/src/client/components/ArticleInfo.tsx index d3e6a59..b14490e 100644 --- a/src/client/components/ArticleInfo.tsx +++ b/src/client/components/ArticleInfo.tsx @@ -18,6 +18,7 @@ interface Props { errorMessages: string[]; qiitaItemUrl: string | null; slide: boolean; + isOlderThanRemote: boolean; } export const ArticleInfo = ({ @@ -28,6 +29,7 @@ export const ArticleInfo = ({ errorMessages, qiitaItemUrl, slide, + isOlderThanRemote, }: Props) => { const [isOpen, setIsOpen] = useState( localStorage.getItem("openInfoState") === "true" ? true : false @@ -88,6 +90,18 @@ export const ArticleInfo = ({ {slide ? "ON" : "OFF"} + {isOlderThanRemote && ( +
+

+ + error + + { + "この記事ファイルの内容は、Qiita上の記事より古い可能性があります。" + } +

+
+ )} {errorMessages.length > 0 && (
{errorMessages.map((errorMessage, index) => ( diff --git a/src/client/components/Header.tsx b/src/client/components/Header.tsx index 6942544..adb6688 100644 --- a/src/client/components/Header.tsx +++ b/src/client/components/Header.tsx @@ -13,6 +13,7 @@ import { Snackbar, Message as SnackbarMessage } from "./Snackbar"; interface Props { id?: string; isItemPublishable: boolean; + isOlderThanRemote: boolean; itemPath: string; basename: string | null; handleMobileOpen: () => void; @@ -21,6 +22,7 @@ interface Props { export const Header = ({ id, isItemPublishable, + isOlderThanRemote, itemPath, basename, handleMobileOpen, @@ -32,6 +34,16 @@ export const Header = ({ const mobileSize = currentWidth <= breakpoint.S; const handlePublish = () => { + if (isOlderThanRemote) { + if ( + !window.confirm( + "この記事はQiita上の記事より古い可能性があります。上書きしますか?" + ) + ) { + return; + } + } + const params = { method: "POST", headers: { diff --git a/src/client/pages/items/show.tsx b/src/client/pages/items/show.tsx index 16bbef7..89843fd 100644 --- a/src/client/pages/items/show.tsx +++ b/src/client/pages/items/show.tsx @@ -84,6 +84,7 @@ export const ItemsShow = () => { isItemPublishable={ item.modified && item.error_messages.length === 0 } + isOlderThanRemote={item.is_older_than_remote} itemPath={item.item_path} id={id} basename={basename} @@ -98,6 +99,7 @@ export const ItemsShow = () => { errorMessages={item.error_messages} qiitaItemUrl={item.qiita_item_url} slide={item.slide} + isOlderThanRemote={item.is_older_than_remote} />
{ const args = arg( { "--all": Boolean, + "--force": Boolean, + "-f": "--force", }, { argv } ); @@ -40,6 +42,7 @@ export const publish = async (argv: string[]) => { } // Validate + const enableForcePublish = args["--force"]; const invalidItemMessages = targetItems.reduce((acc, item) => { const frontmatterErrors = checkFrontmatterType(item); if (frontmatterErrors.length > 0) @@ -49,6 +52,16 @@ export const publish = async (argv: string[]) => { if (validationErrors.length > 0) return [...acc, { name: item.name, errors: validationErrors }]; + if (!enableForcePublish && item.isOlderThanRemote) { + return [ + ...acc, + { + name: item.name, + errors: ["内容がQiita上の記事より古い可能性があります"], + }, + ]; + } + return acc; }, [] as { name: string; errors: string[] }[]); if (invalidItemMessages.length > 0) { diff --git a/src/commands/pull.test.ts b/src/commands/pull.test.ts new file mode 100644 index 0000000..5682ad0 --- /dev/null +++ b/src/commands/pull.test.ts @@ -0,0 +1,61 @@ +import { getFileSystemRepo } from "../lib/get-file-system-repo"; +import { getQiitaApiInstance } from "../lib/get-qiita-api-instance"; +import { syncArticlesFromQiita } from "../lib/sync-articles-from-qiita"; +import { pull } from "./pull"; + +jest.mock("../lib/get-qiita-api-instance"); +jest.mock("../lib/get-file-system-repo"); +jest.mock("../lib/sync-articles-from-qiita"); +const mockGetQiitaApiInstance = jest.mocked(getQiitaApiInstance); +const mockGetFileSystemRepo = jest.mocked(getFileSystemRepo); +const mockSyncArticlesFromQiita = jest.mocked(syncArticlesFromQiita); + +describe("pull", () => { + const qiitaApi = {} as ReturnType; + const fileSystemRepo = {} as ReturnType; + + beforeEach(() => { + mockGetQiitaApiInstance.mockReset(); + mockGetFileSystemRepo.mockReset(); + mockSyncArticlesFromQiita.mockReset(); + + mockGetQiitaApiInstance.mockReturnValue(qiitaApi); + mockGetFileSystemRepo.mockReturnValue(fileSystemRepo); + mockSyncArticlesFromQiita.mockImplementation(); + jest.spyOn(console, "log").mockImplementation(); + }); + + it("pulls articles", async () => { + await pull([]); + + expect(mockSyncArticlesFromQiita).toBeCalledWith({ + fileSystemRepo, + qiitaApi, + forceUpdate: undefined, + }); + expect(mockSyncArticlesFromQiita).toBeCalledTimes(1); + }); + + describe('with "--force" option', () => { + it("pulls articles with forceUpdate", async () => { + await pull(["--force"]); + + expect(mockSyncArticlesFromQiita).toBeCalledWith({ + fileSystemRepo, + qiitaApi, + forceUpdate: true, + }); + expect(mockSyncArticlesFromQiita).toBeCalledTimes(1); + }); + + it("pulls articles with forceUpdate", async () => { + await pull(["-f"]); + + expect(mockSyncArticlesFromQiita).toBeCalledWith({ + fileSystemRepo, + qiitaApi, + forceUpdate: true, + }); + }); + }); +}); diff --git a/src/commands/pull.ts b/src/commands/pull.ts index a88f9f3..4f3c340 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -14,9 +14,9 @@ export const pull = async (argv: string[]) => { const qiitaApi = await getQiitaApiInstance(); const fileSystemRepo = await getFileSystemRepo(); - const isLocalUpdate = args["--force"]; + const forceUpdate = args["--force"]; - await syncArticlesFromQiita({ fileSystemRepo, qiitaApi, isLocalUpdate }); + await syncArticlesFromQiita({ fileSystemRepo, qiitaApi, forceUpdate }); console.log("Sync local articles from Qiita"); console.log("Successful!"); }; diff --git a/src/lib/entities/qiita-item.ts b/src/lib/entities/qiita-item.ts index 3a94364..dd8cb67 100644 --- a/src/lib/entities/qiita-item.ts +++ b/src/lib/entities/qiita-item.ts @@ -8,6 +8,7 @@ export class QiitaItem { public readonly rawBody: string; public readonly name: string; public readonly modified: boolean; + public readonly isOlderThanRemote: boolean; public readonly itemsShowPath: string; public readonly published: boolean; public readonly itemPath: string; @@ -23,6 +24,7 @@ export class QiitaItem { rawBody, name, modified, + isOlderThanRemote, itemsShowPath, published, itemPath, @@ -37,6 +39,7 @@ export class QiitaItem { rawBody: string; name: string; modified: boolean; + isOlderThanRemote: boolean; itemsShowPath: string; published: boolean; itemPath: string; @@ -51,6 +54,7 @@ export class QiitaItem { this.rawBody = rawBody; this.name = name; this.modified = modified; + this.isOlderThanRemote = isOlderThanRemote; this.itemsShowPath = itemsShowPath; this.published = published; this.itemPath = itemPath; diff --git a/src/lib/file-system-repo.test.ts b/src/lib/file-system-repo.test.ts index 8e94e77..eecd281 100644 --- a/src/lib/file-system-repo.test.ts +++ b/src/lib/file-system-repo.test.ts @@ -315,9 +315,9 @@ updated `; const subject = ( beforeSync: boolean = false, - isLocalUpdate: boolean = false + forceUpdate: boolean = false ) => { - return instance.saveItem(item, beforeSync, isLocalUpdate); + return instance.saveItem(item, beforeSync, forceUpdate); }; describe("when local article does not exist", () => { @@ -378,7 +378,7 @@ updated }); }); - describe("when isLocalUpdate is true", () => { + describe("when forceUpdate is true", () => { it("saves item local and remote", () => { const mockFs = fs as jest.Mocked; mockFs.readdir.mockResolvedValueOnce([localFilename] as any[]); diff --git a/src/lib/file-system-repo.ts b/src/lib/file-system-repo.ts index 979a5b8..1309e78 100644 --- a/src/lib/file-system-repo.ts +++ b/src/lib/file-system-repo.ts @@ -135,6 +135,14 @@ class FileContent { ); } + isOlderThan(otherFileContent: FileContent | null): boolean { + if (!otherFileContent) return false; + const updatedAt = new Date(this.updatedAt); + const otherUpdatedAt = new Date(otherFileContent.updatedAt); + + return updatedAt < otherUpdatedAt; + } + clone({ id }: { id: string }): FileContent { return new FileContent({ title: this.title, @@ -264,7 +272,7 @@ export class FileSystemRepo { private async syncItem( item: Item, beforeSync: boolean = false, - isLocalUpdate: boolean = false + forceUpdate: boolean = false ) { const fileContent = FileContent.fromItem(item); @@ -279,7 +287,7 @@ export class FileSystemRepo { true ); - if (data === null || remoteFileContent?.equals(data) || isLocalUpdate) { + if (data === null || remoteFileContent?.equals(data) || forceUpdate) { await this.setItemData(fileContent, true); await this.setItemData(fileContent, false, basename); } else { @@ -287,9 +295,9 @@ export class FileSystemRepo { } } - async saveItems(items: Item[], isLocalUpdate: boolean = false) { + async saveItems(items: Item[], forceUpdate: boolean = false) { const promises = items.map(async (item) => { - await this.syncItem(item, false, isLocalUpdate); + await this.syncItem(item, false, forceUpdate); }); await Promise.all(promises); @@ -298,9 +306,9 @@ export class FileSystemRepo { async saveItem( item: Item, beforeSync: boolean = false, - isLocalUpdate: boolean = false + forceUpdate: boolean = false ) { - await this.syncItem(item, beforeSync, isLocalUpdate); + await this.syncItem(item, beforeSync, forceUpdate); } async loadItems(): Promise { @@ -353,6 +361,7 @@ export class FileSystemRepo { slide: localFileContent.slide, name: basename, modified: !localFileContent.equals(remoteFileContent), + isOlderThanRemote: localFileContent.isOlderThan(remoteFileContent), itemsShowPath: this.generateItemsShowPath(localFileContent.id, basename), published: remoteFileContent !== null, itemPath, @@ -388,6 +397,7 @@ export class FileSystemRepo { slide: localFileContent.slide, name: basename, modified: !localFileContent.equals(remoteFileContent), + isOlderThanRemote: localFileContent.isOlderThan(remoteFileContent), itemsShowPath: this.generateItemsShowPath(localFileContent.id, basename), published: remoteFileContent !== null, itemPath, diff --git a/src/lib/sync-articles-from-qiita.test.ts b/src/lib/sync-articles-from-qiita.test.ts new file mode 100644 index 0000000..ec8a1da --- /dev/null +++ b/src/lib/sync-articles-from-qiita.test.ts @@ -0,0 +1,105 @@ +import type { Item, QiitaApi } from "../qiita-api"; +import type { FileSystemRepo } from "./file-system-repo"; +import { syncArticlesFromQiita } from "./sync-articles-from-qiita"; +import { config } from "./config"; + +jest.mock("./config"); +const mockConfig = jest.mocked(config); + +describe("syncArticlesFromQiita", () => { + const qiitaApi = { + authenticatedUserItems: () => {}, + } as unknown as QiitaApi; + const fileSystemRepo = { + saveItems: () => {}, + } as unknown as FileSystemRepo; + + const mockAuthenticatedUserItems = jest.spyOn( + qiitaApi, + "authenticatedUserItems" + ); + const mockSaveItems = jest.spyOn(fileSystemRepo, "saveItems"); + const mockGetUserConfig = jest.spyOn(mockConfig, "getUserConfig"); + + const items = [{ private: false }, { private: true }] as Item[]; + + beforeEach(() => { + mockAuthenticatedUserItems.mockReset(); + mockSaveItems.mockReset(); + mockGetUserConfig.mockReset(); + + mockAuthenticatedUserItems.mockImplementation(async (page?: number) => { + if (page && page < 2) return items; + return []; + }); + mockSaveItems.mockImplementation(); + }); + + describe("with userConfig", () => { + describe("when includePrivate is true", () => { + it("called saveItems with all item", async () => { + mockGetUserConfig.mockImplementation(async () => ({ + includePrivate: true, + })); + + await syncArticlesFromQiita({ fileSystemRepo, qiitaApi }); + + expect(mockAuthenticatedUserItems).toHaveBeenNthCalledWith(1, 1, 100); + expect(mockAuthenticatedUserItems).toHaveBeenNthCalledWith(2, 2, 100); + expect(mockAuthenticatedUserItems).toBeCalledTimes(2); + expect(mockSaveItems).toHaveBeenCalledWith(items, false); + expect(mockSaveItems).toBeCalledTimes(1); + }); + }); + + describe("when includePrivate is false", () => { + it("called saveItems with only public item", async () => { + mockGetUserConfig.mockImplementation(async () => ({ + includePrivate: false, + })); + + await syncArticlesFromQiita({ fileSystemRepo, qiitaApi }); + + expect(mockAuthenticatedUserItems).toHaveBeenNthCalledWith(1, 1, 100); + expect(mockAuthenticatedUserItems).toHaveBeenNthCalledWith(2, 2, 100); + expect(mockAuthenticatedUserItems).toBeCalledTimes(2); + expect(mockSaveItems).toHaveBeenCalledWith([items[0]], false); + expect(mockSaveItems).toBeCalledTimes(1); + }); + }); + }); + + describe("with forceUpdate", () => { + const expectSaveItemsToBeCalledWithForceUpdate = async ( + forceUpdate: boolean + ) => { + mockGetUserConfig.mockImplementation(async () => ({ + includePrivate: true, + })); + + await syncArticlesFromQiita({ + fileSystemRepo, + qiitaApi, + forceUpdate, + }); + + expect(mockAuthenticatedUserItems).toHaveBeenNthCalledWith(1, 1, 100); + expect(mockAuthenticatedUserItems).toHaveBeenNthCalledWith(2, 2, 100); + expect(mockAuthenticatedUserItems).toBeCalledTimes(2); + expect(mockSaveItems).toHaveBeenCalledWith(items, forceUpdate); + expect(mockSaveItems).toBeCalledTimes(1); + }; + + describe("when forceUpdate is true", () => { + it("called saveItems without forceUpdate", async () => { + await expectSaveItemsToBeCalledWithForceUpdate(true); + }); + }); + + describe("when forceUpdate is false", () => { + it("called saveItems without forceUpdate", async () => { + await expectSaveItemsToBeCalledWithForceUpdate(false); + }); + }); + }); +}); diff --git a/src/lib/sync-articles-from-qiita.ts b/src/lib/sync-articles-from-qiita.ts index 8bd7054..0efd36c 100644 --- a/src/lib/sync-articles-from-qiita.ts +++ b/src/lib/sync-articles-from-qiita.ts @@ -5,11 +5,11 @@ import { config } from "./config"; export const syncArticlesFromQiita = async ({ fileSystemRepo, qiitaApi, - isLocalUpdate = false, + forceUpdate = false, }: { fileSystemRepo: FileSystemRepo; qiitaApi: QiitaApi; - isLocalUpdate?: boolean; + forceUpdate?: boolean; }) => { const per = 100; const userConfig = await config.getUserConfig(); @@ -22,6 +22,6 @@ export const syncArticlesFromQiita = async ({ const result = userConfig.includePrivate ? items : items.filter((item) => !item.private); - await fileSystemRepo.saveItems(result, isLocalUpdate); + await fileSystemRepo.saveItems(result, forceUpdate); } }; diff --git a/src/lib/view-models/items.ts b/src/lib/view-models/items.ts index b2a968f..215d15d 100644 --- a/src/lib/view-models/items.ts +++ b/src/lib/view-models/items.ts @@ -15,6 +15,7 @@ export type ItemsIndexViewModel = { export type ItemsShowViewModel = { error_messages: string[]; + is_older_than_remote: boolean; item_path: string; modified: boolean; organization_url_name: string | null; diff --git a/src/server/api/items.ts b/src/server/api/items.ts index 00f16a8..829d3b4 100644 --- a/src/server/api/items.ts +++ b/src/server/api/items.ts @@ -95,6 +95,7 @@ const itemsShow = async (req: Express.Request, res: Express.Response) => { const result: ItemsShowViewModel = { error_messages: errorMessages, + is_older_than_remote: item.isOlderThanRemote, item_path: itemPath, modified, organization_url_name: item.organizationUrlName,