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,