@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 +18 -0
- package/src/api.ts +98 -0
- package/src/archive.ts +130 -0
- package/src/cli.ts +140 -0
- package/src/config.ts +74 -0
- package/src/frontmatter.ts +54 -0
- package/src/sync.ts +301 -0
- package/src/types.ts +86 -0
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
|
+
}
|