@heliosgraphics/epoque 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@heliosgraphics/epoque",
3
+ "version": "0.0.1-alpha.0",
4
+ "author": "Chris Puska <chris@puska.org>",
5
+ "description": "sync a local folder",
6
+ "type": "module",
7
+ "bin": {
8
+ "epoque": "./src/cli.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "typecheck": "tsc --noEmit --project tsconfig.json"
15
+ },
16
+ "dependencies": {},
17
+ "devDependencies": {}
18
+ }
package/src/api.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import type { RemoteArchive, RemoteArticle, RemoteArticleImage, RemoteCollection } from "./types"
3
+
4
+ interface ApiErrorBody {
5
+ error?: string
6
+ message?: string
7
+ }
8
+
9
+ type JsonBody = Record<string, unknown> | Array<unknown>
10
+
11
+ export class EpoqueApi {
12
+ readonly #apiKey: string
13
+ readonly #origin: string
14
+
15
+ constructor({ apiKey, origin }: { apiKey: string; origin: string }) {
16
+ this.#apiKey = apiKey
17
+ this.#origin = origin.replace(/\/+$/, "")
18
+ }
19
+
20
+ async listCollections(): Promise<Array<RemoteCollection>> {
21
+ return this.#request<Array<RemoteCollection>>("/api/v1/collections")
22
+ }
23
+
24
+ async getCollection(id: string): Promise<RemoteCollection> {
25
+ return this.#request<RemoteCollection>(`/api/v1/collections/${encodeURIComponent(id)}`)
26
+ }
27
+
28
+ async getArchive(collectionId: string): Promise<RemoteArchive> {
29
+ return this.#request<RemoteArchive>(`/api/v1/collections/${encodeURIComponent(collectionId)}/archive`)
30
+ }
31
+
32
+ async createArticle(collectionId: string): Promise<RemoteArticle> {
33
+ return this.#request<RemoteArticle>("/api/v1/articles", { method: "POST", json: { collectionId } })
34
+ }
35
+
36
+ async updateArticle({ articleId, values }: { articleId: string; values: JsonBody }): Promise<RemoteArticle> {
37
+ return this.#request<RemoteArticle>(`/api/v1/articles/${encodeURIComponent(articleId)}`, { method: "PUT", json: values })
38
+ }
39
+
40
+ async deleteArticle(articleId: string): Promise<void> {
41
+ await this.#request<unknown>(`/api/v1/articles/${encodeURIComponent(articleId)}`, { method: "DELETE" })
42
+ }
43
+
44
+ async uploadAttachment({ articleId, path }: { articleId: string; path: string }): Promise<RemoteArticleImage> {
45
+ const body = await readFile(path)
46
+
47
+ return this.#request<RemoteArticleImage>(`/api/v1/article-images?articleId=${encodeURIComponent(articleId)}`, {
48
+ body,
49
+ method: "POST",
50
+ })
51
+ }
52
+
53
+ async deleteAttachment(url: string): Promise<void> {
54
+ await this.#request<unknown>("/api/v1/article-images", { method: "DELETE", json: { url } })
55
+ }
56
+
57
+ async reorderAttachments({ articleId, imageIds }: { articleId: string; imageIds: Array<string> }): Promise<void> {
58
+ await this.#request<unknown>(`/api/v1/article-images?articleId=${encodeURIComponent(articleId)}`, {
59
+ method: "PUT",
60
+ json: imageIds,
61
+ })
62
+ }
63
+
64
+ async #request<T>(path: string, options: { method?: string; json?: JsonBody; body?: Uint8Array } = {}): Promise<T> {
65
+ const headers = new Headers({ Authorization: `Bearer ${this.#apiKey}` })
66
+ let body: BodyInit | undefined
67
+
68
+ if (options.json !== undefined) {
69
+ headers.set("Content-Type", "application/json")
70
+ body = JSON.stringify(options.json)
71
+ } else if (options.body) {
72
+ headers.set("Content-Type", "application/octet-stream")
73
+ const bytes = new Uint8Array(options.body.byteLength)
74
+ bytes.set(options.body)
75
+ body = new Blob([bytes])
76
+ }
77
+
78
+ const response = await fetch(`${this.#origin}${path}`, { body, headers, method: options.method ?? "GET" })
79
+
80
+ if (!response.ok) {
81
+ throw new Error(await getResponseError(response))
82
+ }
83
+
84
+ if (response.status === 204) return undefined as T
85
+
86
+ return response.json() as Promise<T>
87
+ }
88
+ }
89
+
90
+ const getResponseError = async (response: Response): Promise<string> => {
91
+ try {
92
+ const body = (await response.json()) as ApiErrorBody
93
+
94
+ return body.error || body.message || `request failed with ${response.status}`
95
+ } catch (_error) {
96
+ return `request failed with ${response.status}`
97
+ }
98
+ }
package/src/archive.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"
2
+ import { basename, dirname, extname, join, relative } from "node:path"
3
+ import { parseFrontmatter, serializeFrontmatter } from "./frontmatter"
4
+ import type { LocalArticle, LocalAttachment, RemoteArchive, RemoteArticle } from "./types"
5
+
6
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"])
7
+ const ATTACHMENTS_DIRECTORY = "attachments" as const
8
+
9
+ const toPortablePath = (path: string): string => path.split("\\").join("/")
10
+
11
+ const getString = (value: unknown): string | null => (typeof value === "string" && value.trim() ? value.trim() : null)
12
+
13
+ const getBoolean = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null)
14
+
15
+ const getAttachmentHash = async (absolutePath: string): Promise<string> => {
16
+ const buffer = await readFile(absolutePath)
17
+ const digest = await crypto.subtle.digest("SHA-256", buffer)
18
+ const bytes = new Uint8Array(digest)
19
+
20
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
21
+ }
22
+
23
+ const listMarkdownFiles = async (cwd: string, directory: string = cwd): Promise<Array<string>> => {
24
+ const entries = await readdir(directory, { withFileTypes: true })
25
+ const files: Array<string> = []
26
+
27
+ for (const entry of entries) {
28
+ if (entry.name === ATTACHMENTS_DIRECTORY || entry.name === "node_modules" || entry.name.startsWith(".")) continue
29
+
30
+ const absolutePath = join(directory, entry.name)
31
+ if (entry.isDirectory()) {
32
+ files.push(...(await listMarkdownFiles(cwd, absolutePath)))
33
+ continue
34
+ }
35
+
36
+ if (entry.isFile() && MARKDOWN_EXTENSIONS.has(extname(entry.name))) {
37
+ files.push(absolutePath)
38
+ }
39
+ }
40
+
41
+ return files.sort((left, right) => left.localeCompare(right))
42
+ }
43
+
44
+ const readAttachmentDirectory = async (cwd: string, articleKeys: Array<string>): Promise<Array<LocalAttachment>> => {
45
+ for (const key of articleKeys) {
46
+ const directory = join(cwd, ATTACHMENTS_DIRECTORY, key)
47
+
48
+ try {
49
+ const entries = await readdir(directory, { withFileTypes: true })
50
+ const attachments: Array<LocalAttachment> = []
51
+
52
+ for (const entry of entries) {
53
+ if (!entry.isFile()) continue
54
+
55
+ const absolutePath = join(directory, entry.name)
56
+ const relativePath = toPortablePath(relative(cwd, absolutePath))
57
+
58
+ attachments.push({
59
+ absolutePath,
60
+ hash: await getAttachmentHash(absolutePath),
61
+ name: entry.name,
62
+ relativePath,
63
+ })
64
+ }
65
+
66
+ return attachments.sort((left, right) => left.relativePath.localeCompare(right.relativePath))
67
+ } catch (_error) {}
68
+ }
69
+
70
+ return []
71
+ }
72
+
73
+ export const readArchive = async (cwd: string): Promise<Array<LocalArticle>> => {
74
+ const files = await listMarkdownFiles(cwd)
75
+ const articles: Array<LocalArticle> = []
76
+
77
+ for (const absolutePath of files) {
78
+ const content = await readFile(absolutePath, "utf8")
79
+ const { frontmatter, body } = parseFrontmatter(content)
80
+ const id = getString(frontmatter.id)
81
+ const slug = getString(frontmatter.slug)
82
+ const file = toPortablePath(relative(cwd, absolutePath))
83
+ const fileKey = basename(file, extname(file))
84
+ const publishedAt = getString(frontmatter.publishedAt)
85
+ const isPublished = getBoolean(frontmatter.published) ?? getBoolean(frontmatter.isPublished) ?? Boolean(publishedAt)
86
+ const attachments = await readAttachmentDirectory(
87
+ cwd,
88
+ [id, slug, fileKey].filter((value): value is string => !!value),
89
+ )
90
+
91
+ articles.push({
92
+ absolutePath,
93
+ attachments,
94
+ body,
95
+ createdAt: getString(frontmatter.createdAt),
96
+ file,
97
+ frontmatter,
98
+ id,
99
+ isPublished,
100
+ location: getString(frontmatter.location),
101
+ publishedAt,
102
+ slug,
103
+ })
104
+ }
105
+
106
+ return articles
107
+ }
108
+
109
+ export const writeArticleFile = async ({ article, body }: { article: LocalArticle; body?: string }): Promise<void> => {
110
+ await mkdir(dirname(article.absolutePath), { recursive: true })
111
+ await writeFile(article.absolutePath, serializeFrontmatter(article.frontmatter, body ?? article.body))
112
+ }
113
+
114
+ const getArticleFileName = (article: RemoteArticle): string => `${article.id}.md`
115
+
116
+ export const buildArchiveFromRemote = (archive: RemoteArchive): Array<{ file: string; content: string }> =>
117
+ archive.articles.map((article) => ({
118
+ file: getArticleFileName(article),
119
+ content: serializeFrontmatter(
120
+ {
121
+ id: article.id,
122
+ slug: article.slug,
123
+ published: article.isPublished,
124
+ publishedAt: article.publishedAt,
125
+ createdAt: article.createdAt,
126
+ location: article.location,
127
+ },
128
+ article.post ?? "",
129
+ ),
130
+ }))
package/src/cli.ts ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { createInterface } from "node:readline/promises"
4
+ import { stdin as input, stdout as output } from "node:process"
5
+ import { EpoqueApi } from "./api"
6
+ import { getApiKey, getStoredOrigin, normalizeOrigin, readGlobalConfig, readProjectConfig, writeGlobalConfig } from "./config"
7
+ import { syncFolder } from "./sync"
8
+
9
+ const HELP = `Usage:
10
+ epoque login [--origin https://epoque.app]
11
+ epoque sync [--dry-run] [--origin https://epoque.app]
12
+ `
13
+
14
+ const promptLine = async (question: string): Promise<string> => {
15
+ const readline = createInterface({ input, output })
16
+
17
+ try {
18
+ return await readline.question(question)
19
+ } finally {
20
+ readline.close()
21
+ }
22
+ }
23
+
24
+ const promptSecret = async (question: string): Promise<string> => {
25
+ const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => void }
26
+
27
+ if (!stdin.isTTY || !stdin.setRawMode) return promptLine(question)
28
+
29
+ return new Promise((resolve, reject) => {
30
+ let value = ""
31
+
32
+ const cleanup = (): void => {
33
+ stdin.off("data", onData)
34
+ stdin.setRawMode?.(false)
35
+ stdin.pause()
36
+ process.stdout.write("\n")
37
+ }
38
+
39
+ const onData = (data: Buffer): void => {
40
+ const text = data.toString("utf8")
41
+
42
+ for (const character of text) {
43
+ if (character === "\u0003") {
44
+ cleanup()
45
+ reject(new Error("cancelled"))
46
+ return
47
+ }
48
+
49
+ if (character === "\r" || character === "\n") {
50
+ cleanup()
51
+ resolve(value)
52
+ return
53
+ }
54
+
55
+ if (character === "\u007f") {
56
+ value = value.slice(0, -1)
57
+ continue
58
+ }
59
+
60
+ value += character
61
+ }
62
+ }
63
+
64
+ process.stdout.write(question)
65
+ stdin.resume()
66
+ stdin.setRawMode(true)
67
+ stdin.on("data", onData)
68
+ })
69
+ }
70
+
71
+ const getFlagValue = (args: Array<string>, name: string): string | undefined => {
72
+ const index = args.indexOf(name)
73
+ if (index === -1) return undefined
74
+
75
+ return args[index + 1]
76
+ }
77
+
78
+ const hasFlag = (args: Array<string>, name: string): boolean => args.includes(name)
79
+
80
+ const login = async (args: Array<string>): Promise<void> => {
81
+ const origin = normalizeOrigin(getFlagValue(args, "--origin") ?? (await getStoredOrigin()))
82
+ const apiKey = (await promptSecret("API key: ")).trim()
83
+
84
+ if (!apiKey) throw new Error("api key is required")
85
+
86
+ const api = new EpoqueApi({ apiKey, origin })
87
+ await api.listCollections()
88
+
89
+ const existing = await readGlobalConfig()
90
+ await writeGlobalConfig({ ...existing, apiKey, origin })
91
+ console.log("Saved API key to ~/.config/epoque/config.json")
92
+ }
93
+
94
+ const sync = async (args: Array<string>): Promise<void> => {
95
+ const cwd = process.cwd()
96
+ const projectConfig = await readProjectConfig(cwd)
97
+ const origin = normalizeOrigin(getFlagValue(args, "--origin") ?? projectConfig?.origin ?? (await getStoredOrigin()))
98
+ const apiKey = await getApiKey()
99
+
100
+ if (!apiKey) throw new Error("not logged in. Run `epoque login` first or set EPOQUE_API_KEY")
101
+
102
+ const summary = await syncFolder({
103
+ api: new EpoqueApi({ apiKey, origin }),
104
+ cwd,
105
+ dryRun: hasFlag(args, "--dry-run"),
106
+ origin,
107
+ prompt: promptLine,
108
+ })
109
+
110
+ console.log(
111
+ `Articles: ${summary.createdArticles} created, ${summary.updatedArticles} updated, ${summary.deletedArticles} deleted`,
112
+ )
113
+ console.log(`Attachments: ${summary.uploadedAttachments} uploaded, ${summary.deletedAttachments} deleted`)
114
+ }
115
+
116
+ const main = async (): Promise<void> => {
117
+ const [command, ...args] = process.argv.slice(2)
118
+
119
+ switch (command) {
120
+ case "login":
121
+ await login(args)
122
+ return
123
+ case "sync":
124
+ await sync(args)
125
+ return
126
+ case "help":
127
+ case "--help":
128
+ case "-h":
129
+ case undefined:
130
+ console.log(HELP)
131
+ return
132
+ default:
133
+ throw new Error(`unknown command: ${command}`)
134
+ }
135
+ }
136
+
137
+ main().catch((error: unknown) => {
138
+ console.error(error instanceof Error ? error.message : String(error))
139
+ process.exitCode = 1
140
+ })
package/src/config.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+ import { homedir } from "node:os"
4
+ import type { GlobalConfig, ProjectConfig } from "./types"
5
+
6
+ const DEFAULT_ORIGIN = "https://epoque.app" as const
7
+ const PROJECT_CONFIG_FILE = "epoque.json" as const
8
+ const PROJECT_SCHEMA_URL = "https://epoque.app/schemas/epoque.json" as const
9
+
10
+ export const normalizeOrigin = (origin: string | undefined): string => (origin || DEFAULT_ORIGIN).replace(/\/+$/, "")
11
+
12
+ export const getProjectConfigPath = (cwd: string): string => join(cwd, PROJECT_CONFIG_FILE)
13
+
14
+ const getGlobalConfigPath = (): string => {
15
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
16
+
17
+ return join(configHome, "epoque", "config.json")
18
+ }
19
+
20
+ export const readGlobalConfig = async (): Promise<GlobalConfig> => {
21
+ try {
22
+ const content = await readFile(getGlobalConfigPath(), "utf8")
23
+
24
+ return JSON.parse(content) as GlobalConfig
25
+ } catch (_error) {
26
+ return {}
27
+ }
28
+ }
29
+
30
+ export const writeGlobalConfig = async (config: GlobalConfig): Promise<void> => {
31
+ const configPath = getGlobalConfigPath()
32
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 })
33
+ await writeFile(configPath, `${JSON.stringify(config, null, "\t")}\n`, { mode: 0o600 })
34
+ await chmod(configPath, 0o600)
35
+ }
36
+
37
+ export const getApiKey = async (): Promise<string | null> => {
38
+ if (process.env.EPOQUE_API_KEY) return process.env.EPOQUE_API_KEY
39
+
40
+ const config = await readGlobalConfig()
41
+
42
+ return config.apiKey ?? null
43
+ }
44
+
45
+ export const getStoredOrigin = async (originOverride?: string): Promise<string> => {
46
+ if (originOverride) return normalizeOrigin(originOverride)
47
+
48
+ const config = await readGlobalConfig()
49
+
50
+ return normalizeOrigin(config.origin)
51
+ }
52
+
53
+ export const readProjectConfig = async (cwd: string): Promise<ProjectConfig | null> => {
54
+ try {
55
+ const content = await readFile(getProjectConfigPath(cwd), "utf8")
56
+
57
+ return JSON.parse(content) as ProjectConfig
58
+ } catch (_error) {
59
+ return null
60
+ }
61
+ }
62
+
63
+ export const writeProjectConfig = async (cwd: string, config: ProjectConfig): Promise<void> => {
64
+ await writeFile(getProjectConfigPath(cwd), `${JSON.stringify(config, null, "\t")}\n`)
65
+ }
66
+
67
+ export const createProjectConfig = ({ origin, collectionId }: { origin: string; collectionId: string }): ProjectConfig => ({
68
+ $schema: PROJECT_SCHEMA_URL,
69
+ version: 1,
70
+ origin: normalizeOrigin(origin),
71
+ collectionId,
72
+ lastSyncedAt: null,
73
+ articles: {},
74
+ })
@@ -0,0 +1,54 @@
1
+ const FRONTMATTER_PATTERN: RegExp = /^---\s*\n([\s\S]*?)\n---\s*\n?/
2
+
3
+ const parseValue = (value: string): unknown => {
4
+ const trimmed = value.trim()
5
+
6
+ if (trimmed === "null") return null
7
+ if (trimmed === "true") return true
8
+ if (trimmed === "false") return false
9
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed)
10
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
11
+ return trimmed.slice(1, -1)
12
+ }
13
+
14
+ return trimmed
15
+ }
16
+
17
+ const serializeValue = (value: unknown): string => {
18
+ if (value === null) return "null"
19
+ if (typeof value === "boolean" || typeof value === "number") return String(value)
20
+ if (typeof value === "string") return JSON.stringify(value)
21
+
22
+ return JSON.stringify(value)
23
+ }
24
+
25
+ export const parseFrontmatter = (content: string): { frontmatter: Record<string, unknown>; body: string } => {
26
+ const match = content.match(FRONTMATTER_PATTERN)
27
+ if (!match) return { frontmatter: {}, body: content }
28
+
29
+ const frontmatter: Record<string, unknown> = {}
30
+ const rawFrontmatter = match[1] ?? ""
31
+ const body = content.slice(match[0].length)
32
+
33
+ for (const line of rawFrontmatter.split("\n")) {
34
+ const trimmed = line.trim()
35
+ if (!trimmed || trimmed.startsWith("#")) continue
36
+
37
+ const separatorIndex = trimmed.indexOf(":")
38
+ if (separatorIndex === -1) continue
39
+
40
+ const key = trimmed.slice(0, separatorIndex).trim()
41
+ const value = trimmed.slice(separatorIndex + 1)
42
+ if (key) frontmatter[key] = parseValue(value)
43
+ }
44
+
45
+ return { frontmatter, body }
46
+ }
47
+
48
+ export const serializeFrontmatter = (frontmatter: Record<string, unknown>, body: string): string => {
49
+ const lines = Object.entries(frontmatter)
50
+ .filter(([, value]) => value !== undefined)
51
+ .map(([key, value]) => `${key}: ${serializeValue(value)}`)
52
+
53
+ return `---\n${lines.join("\n")}\n---\n\n${body.trimStart()}`
54
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,301 @@
1
+ import { basename, extname } from "node:path"
2
+ import { readArchive, writeArticleFile } from "./archive"
3
+ import { createProjectConfig, readProjectConfig, writeProjectConfig } from "./config"
4
+ import type { EpoqueApi } from "./api"
5
+ import type { ArticleSyncState, LocalArticle, ProjectConfig, RemoteArchive, RemoteArticle } from "./types"
6
+
7
+ type Prompt = (question: string) => Promise<string>
8
+
9
+ export interface SyncOptions {
10
+ cwd: string
11
+ origin: string
12
+ api: EpoqueApi
13
+ dryRun: boolean
14
+ prompt: Prompt
15
+ }
16
+
17
+ export interface SyncSummary {
18
+ createdArticles: number
19
+ updatedArticles: number
20
+ deletedArticles: number
21
+ uploadedAttachments: number
22
+ deletedAttachments: number
23
+ reorderedArticles: number
24
+ }
25
+
26
+ const EMPTY_SUMMARY: SyncSummary = {
27
+ createdArticles: 0,
28
+ deletedArticles: 0,
29
+ deletedAttachments: 0,
30
+ reorderedArticles: 0,
31
+ updatedArticles: 0,
32
+ uploadedAttachments: 0,
33
+ }
34
+
35
+ const isUuid = (value: string): boolean =>
36
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
37
+
38
+ const isAfter = (left: string | null | undefined, right: string | null | undefined): boolean => {
39
+ if (!left || !right) return false
40
+
41
+ return new Date(left).getTime() > new Date(right).getTime()
42
+ }
43
+
44
+ const getCollectionSearchTerms = (input: string): Array<string> => {
45
+ try {
46
+ const url = new URL(input)
47
+ const segments = url.pathname
48
+ .split("/")
49
+ .map((segment) => segment.trim())
50
+ .filter(Boolean)
51
+ const hostSegments = url.hostname.split(".").filter(Boolean)
52
+
53
+ return [...segments, ...hostSegments, input]
54
+ } catch (_error) {
55
+ return [input]
56
+ }
57
+ }
58
+
59
+ const resolveCollectionId = async ({ api, prompt }: { api: EpoqueApi; prompt: Prompt }): Promise<string> => {
60
+ const input = (await prompt("Collection name, id, or URL: ")).trim()
61
+ if (!input) throw new Error("collection is required")
62
+ if (isUuid(input)) return (await api.getCollection(input)).id
63
+
64
+ const terms = getCollectionSearchTerms(input).map((term) => term.toLowerCase())
65
+ const collections = await api.listCollections()
66
+ const matches = collections.filter((collection) => {
67
+ const values = [collection.id, collection.title, collection.subdomain].map((value) => value.toLowerCase())
68
+
69
+ return terms.some((term) => values.includes(term))
70
+ })
71
+
72
+ if (matches.length === 1) return matches[0].id
73
+ if (!matches.length) throw new Error(`collection not found: ${input}`)
74
+
75
+ console.log("Multiple collections matched:")
76
+ matches.forEach((collection, index) => console.log(`${index + 1}. ${collection.title} (${collection.subdomain})`))
77
+
78
+ const selected = Number.parseInt(await prompt("Select a collection number: "), 10)
79
+ const collection = matches[selected - 1]
80
+ if (!collection) throw new Error("invalid collection selection")
81
+
82
+ return collection.id
83
+ }
84
+
85
+ const ensureProjectConfig = async ({ api, cwd, dryRun, origin, prompt }: SyncOptions): Promise<ProjectConfig> => {
86
+ const existing = await readProjectConfig(cwd)
87
+ if (existing) return { ...existing, origin }
88
+
89
+ const collectionId = await resolveCollectionId({ api, prompt })
90
+ const config = createProjectConfig({ collectionId, origin })
91
+
92
+ if (!dryRun) await writeProjectConfig(cwd, config)
93
+
94
+ return config
95
+ }
96
+
97
+ const getLocalById = (articles: Array<LocalArticle>): Map<string, LocalArticle> => {
98
+ const map = new Map<string, LocalArticle>()
99
+
100
+ for (const article of articles) {
101
+ if (article.id) map.set(article.id, article)
102
+ }
103
+
104
+ return map
105
+ }
106
+
107
+ const getRemoteById = (archive: RemoteArchive): Map<string, RemoteArticle> =>
108
+ new Map(archive.articles.map((article) => [article.id, article]))
109
+
110
+ const assertNoUpstreamChanges = ({
111
+ archive,
112
+ config,
113
+ localArticles,
114
+ }: {
115
+ archive: RemoteArchive
116
+ config: ProjectConfig
117
+ localArticles: Array<LocalArticle>
118
+ }): void => {
119
+ const conflicts: Array<string> = []
120
+ const localById = getLocalById(localArticles)
121
+
122
+ if (isAfter(archive.collection.updatedAt, config.lastSyncedAt)) {
123
+ conflicts.push(`collection updated upstream at ${archive.collection.updatedAt}`)
124
+ }
125
+
126
+ for (const remoteArticle of archive.articles) {
127
+ const state = config.articles[remoteArticle.id]
128
+
129
+ if (!state && !localById.has(remoteArticle.id)) {
130
+ conflicts.push(`untracked remote article ${remoteArticle.id}`)
131
+ continue
132
+ }
133
+
134
+ if (!state && remoteArticle.images.length) {
135
+ conflicts.push(`untracked remote attachments for article ${remoteArticle.id}`)
136
+ continue
137
+ }
138
+
139
+ if (state?.remoteUpdatedAt && isAfter(remoteArticle.updatedAt, state.remoteUpdatedAt)) {
140
+ conflicts.push(`article ${remoteArticle.id} updated upstream at ${remoteArticle.updatedAt}`)
141
+ }
142
+
143
+ const trackedImageIds = new Set(Object.values(state?.attachments ?? {}).map((attachment) => attachment.id))
144
+ for (const image of remoteArticle.images) {
145
+ if (!trackedImageIds.has(image.id))
146
+ conflicts.push(`untracked remote attachment ${image.id} on article ${remoteArticle.id}`)
147
+ }
148
+ }
149
+
150
+ if (conflicts.length) {
151
+ throw new Error(`sync aborted because upstream is newer:\n${conflicts.map((conflict) => `- ${conflict}`).join("\n")}`)
152
+ }
153
+ }
154
+
155
+ const getUpdateBody = (article: LocalArticle): Record<string, unknown> => ({
156
+ isPublished: article.isPublished,
157
+ location: article.location,
158
+ post: article.body,
159
+ publishedAt: article.publishedAt,
160
+ slug: article.slug,
161
+ })
162
+
163
+ const updateLocalFrontmatter = ({ article, remote }: { article: LocalArticle; remote: RemoteArticle }): void => {
164
+ article.id = remote.id
165
+ article.frontmatter.id = remote.id
166
+ article.frontmatter.slug = remote.slug
167
+ article.frontmatter.published = remote.isPublished
168
+ article.frontmatter.publishedAt = remote.publishedAt
169
+ article.frontmatter.createdAt = remote.createdAt
170
+ article.frontmatter.location = remote.location
171
+ }
172
+
173
+ const getNewArticleFileKey = (article: LocalArticle): string => basename(article.file, extname(article.file))
174
+
175
+ const syncAttachments = async ({
176
+ api,
177
+ article,
178
+ dryRun,
179
+ state,
180
+ summary,
181
+ }: {
182
+ api: EpoqueApi
183
+ article: LocalArticle
184
+ dryRun: boolean
185
+ state: ArticleSyncState
186
+ summary: SyncSummary
187
+ }): Promise<ArticleSyncState> => {
188
+ const nextState: ArticleSyncState = { ...state, attachments: {} }
189
+ const localPaths = new Set(article.attachments.map((attachment) => attachment.relativePath))
190
+ const imageIds: Array<string> = []
191
+
192
+ for (const attachment of article.attachments) {
193
+ const existing = state.attachments[attachment.relativePath]
194
+
195
+ if (existing?.hash === attachment.hash) {
196
+ nextState.attachments[attachment.relativePath] = existing
197
+ imageIds.push(existing.id)
198
+ continue
199
+ }
200
+
201
+ if (existing) {
202
+ summary.deletedAttachments += 1
203
+ if (!dryRun) await api.deleteAttachment(existing.url)
204
+ }
205
+
206
+ summary.uploadedAttachments += 1
207
+ if (dryRun) continue
208
+
209
+ const uploaded = await api.uploadAttachment({
210
+ articleId: article.id ?? getNewArticleFileKey(article),
211
+ path: attachment.absolutePath,
212
+ })
213
+ nextState.attachments[attachment.relativePath] = { hash: attachment.hash, id: uploaded.id, url: uploaded.url }
214
+ imageIds.push(uploaded.id)
215
+ }
216
+
217
+ for (const [relativePath, attachment] of Object.entries(state.attachments)) {
218
+ if (localPaths.has(relativePath)) continue
219
+
220
+ summary.deletedAttachments += 1
221
+ if (!dryRun) await api.deleteAttachment(attachment.url)
222
+ }
223
+
224
+ if (imageIds.length) {
225
+ summary.reorderedArticles += 1
226
+ if (!dryRun) await api.reorderAttachments({ articleId: article.id ?? getNewArticleFileKey(article), imageIds })
227
+ }
228
+
229
+ return nextState
230
+ }
231
+
232
+ export const syncFolder = async (options: SyncOptions): Promise<SyncSummary> => {
233
+ const config = await ensureProjectConfig(options)
234
+ const archive = await options.api.getArchive(config.collectionId)
235
+ const localArticles = await readArchive(options.cwd)
236
+ const remoteById = getRemoteById(archive)
237
+ const localById = getLocalById(localArticles)
238
+ const summary: SyncSummary = { ...EMPTY_SUMMARY }
239
+
240
+ assertNoUpstreamChanges({ archive, config, localArticles })
241
+
242
+ for (const articleId of Object.keys(config.articles)) {
243
+ if (localById.has(articleId) || !remoteById.has(articleId)) continue
244
+
245
+ summary.deletedArticles += 1
246
+ if (!options.dryRun) await options.api.deleteArticle(articleId)
247
+ delete config.articles[articleId]
248
+ }
249
+
250
+ for (const localArticle of localArticles) {
251
+ let articleId = localArticle.id
252
+ let remoteArticle = articleId ? remoteById.get(articleId) : undefined
253
+
254
+ if (!articleId) {
255
+ summary.createdArticles += 1
256
+
257
+ if (!options.dryRun) {
258
+ const createdArticle = await options.api.createArticle(config.collectionId)
259
+ articleId = createdArticle.id
260
+ localArticle.id = articleId
261
+ remoteArticle = createdArticle
262
+ }
263
+ } else {
264
+ summary.updatedArticles += 1
265
+ }
266
+
267
+ if (!options.dryRun && articleId) {
268
+ remoteArticle = await options.api.updateArticle({ articleId, values: getUpdateBody(localArticle) })
269
+ updateLocalFrontmatter({ article: localArticle, remote: remoteArticle })
270
+ await writeArticleFile({ article: localArticle })
271
+ }
272
+
273
+ if (!articleId) continue
274
+
275
+ const state: ArticleSyncState = config.articles[articleId] ?? {
276
+ attachments: {},
277
+ file: localArticle.file,
278
+ remoteUpdatedAt: null,
279
+ }
280
+ const syncedState = await syncAttachments({
281
+ api: options.api,
282
+ article: localArticle,
283
+ dryRun: options.dryRun,
284
+ state: { ...state, file: localArticle.file, remoteUpdatedAt: remoteArticle?.updatedAt ?? state.remoteUpdatedAt },
285
+ summary,
286
+ })
287
+
288
+ config.articles[articleId] = {
289
+ ...syncedState,
290
+ file: localArticle.file,
291
+ remoteUpdatedAt: remoteArticle?.updatedAt ?? syncedState.remoteUpdatedAt,
292
+ }
293
+ }
294
+
295
+ config.lastSyncedAt = new Date().toISOString()
296
+ config.origin = options.origin
297
+
298
+ if (!options.dryRun) await writeProjectConfig(options.cwd, config)
299
+
300
+ return summary
301
+ }
package/src/types.ts ADDED
@@ -0,0 +1,86 @@
1
+ export interface RemoteCollection {
2
+ id: string
3
+ title: string
4
+ subdomain: string
5
+ description: string | null
6
+ updatedAt: string
7
+ }
8
+
9
+ export interface RemoteArticleImage {
10
+ id: string
11
+ articleId: string
12
+ url: string
13
+ width: number
14
+ height: number
15
+ color: string | null
16
+ description: string | null
17
+ source: string | null
18
+ createdAt: string
19
+ updatedAt: string | null
20
+ }
21
+
22
+ export interface RemoteArticle {
23
+ id: string
24
+ collectionId: string
25
+ categoryId: string | null
26
+ createdAt: string
27
+ updatedAt: string
28
+ isPublished: boolean
29
+ location: string | null
30
+ post: string | null
31
+ publishedAt: string | null
32
+ slug: string | null
33
+ images: Array<RemoteArticleImage>
34
+ }
35
+
36
+ export interface RemoteArchive {
37
+ collection: RemoteCollection
38
+ articles: Array<RemoteArticle>
39
+ }
40
+
41
+ export interface AttachmentSyncState {
42
+ id: string
43
+ url: string
44
+ hash: string
45
+ }
46
+
47
+ export interface ArticleSyncState {
48
+ file: string
49
+ remoteUpdatedAt: string | null
50
+ attachments: Record<string, AttachmentSyncState>
51
+ }
52
+
53
+ export interface ProjectConfig {
54
+ $schema?: string
55
+ version: 1
56
+ origin: string
57
+ collectionId: string
58
+ lastSyncedAt: string | null
59
+ articles: Record<string, ArticleSyncState>
60
+ }
61
+
62
+ export interface GlobalConfig {
63
+ origin?: string
64
+ apiKey?: string
65
+ }
66
+
67
+ export interface LocalAttachment {
68
+ relativePath: string
69
+ absolutePath: string
70
+ name: string
71
+ hash: string
72
+ }
73
+
74
+ export interface LocalArticle {
75
+ file: string
76
+ absolutePath: string
77
+ frontmatter: Record<string, unknown>
78
+ id: string | null
79
+ slug: string | null
80
+ isPublished: boolean
81
+ publishedAt: string | null
82
+ createdAt: string | null
83
+ location: string | null
84
+ body: string
85
+ attachments: Array<LocalAttachment>
86
+ }