init
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# LMS Translation API
|
||||||
|
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
||||||
|
TRANSLATE_MODEL=plamo-2-translate
|
||||||
36
.github/workflows/cf-pages.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build content from ATProto
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
uses: cloudflare/pages-action@v1
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||||
|
directory: dist
|
||||||
|
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/dist
|
||||||
|
/repos
|
||||||
|
/target
|
||||||
|
/CLAUDE.md
|
||||||
|
/.claude
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
Cargo.lock
|
||||||
|
.env
|
||||||
24
Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "ailog"
|
||||||
|
version = "0.0.1"
|
||||||
|
edition = "2021"
|
||||||
|
description = "ATProto blog CLI"
|
||||||
|
authors = ["syui"]
|
||||||
|
homepage = "https://syui.ai"
|
||||||
|
repository = "https://git.syui.ai/ai/log"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ailog"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
dirs = "5.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
rand = "0.8"
|
||||||
|
dotenvy = "0.15"
|
||||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>syui.ai</title>
|
||||||
|
<link rel="stylesheet" href="/src/styles/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
lexicons/ai.syui.log.post.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "ai.syui.log.post",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "record",
|
||||||
|
"description": "Record containing a blog post.",
|
||||||
|
"key": "tid",
|
||||||
|
"record": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title", "content", "createdAt"],
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 3000,
|
||||||
|
"maxGraphemes": 300,
|
||||||
|
"description": "The title of the post."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1000000,
|
||||||
|
"maxGraphemes": 100000,
|
||||||
|
"description": "The content of the post (markdown)."
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "datetime",
|
||||||
|
"description": "Client-declared timestamp when this post was originally created."
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 10,
|
||||||
|
"description": "Language code of the original content (e.g., 'ja', 'en')."
|
||||||
|
},
|
||||||
|
"translations": {
|
||||||
|
"type": "ref",
|
||||||
|
"ref": "#translationMap",
|
||||||
|
"description": "Translations of the post in other languages."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"translationMap": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Map of language codes to translations.",
|
||||||
|
"properties": {
|
||||||
|
"en": { "type": "ref", "ref": "#translation" },
|
||||||
|
"ja": { "type": "ref", "ref": "#translation" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A translation of a post.",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 3000,
|
||||||
|
"maxGraphemes": 300
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1000000,
|
||||||
|
"maxGraphemes": 100000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "ailog",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto/api": "^0.15.12",
|
||||||
|
"@atproto/oauth-client-browser": "^0.3.19",
|
||||||
|
"marked": "^15.0.6",
|
||||||
|
"highlight.js": "^11.11.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
public/_redirects
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/app/* /index.html 200
|
||||||
|
/oauth/* /index.html 200
|
||||||
|
/* /index.html 200
|
||||||
16
public/ai.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
12
public/client-metadata.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"client_id": "https://syui.ai/client-metadata.json",
|
||||||
|
"client_name": "syui.ai",
|
||||||
|
"client_uri": "https://syui.ai",
|
||||||
|
"redirect_uris": ["https://syui.ai/oauth/callback"],
|
||||||
|
"scope": "atproto transition:generic",
|
||||||
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
|
"response_types": ["code"],
|
||||||
|
"application_type": "web",
|
||||||
|
"token_endpoint_auth_method": "none",
|
||||||
|
"dpop_bound_access_tokens": true
|
||||||
|
}
|
||||||
8
public/config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"title": "syui.ai",
|
||||||
|
"handle": "syui.syui.ai",
|
||||||
|
"collection": "ai.syui.log.post",
|
||||||
|
"network": "syu.is",
|
||||||
|
"color": "#EF454A",
|
||||||
|
"siteUrl": "https://syui.ai"
|
||||||
|
}
|
||||||
67
public/favicon.svg
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
syui
|
||||||
|
</metadata>
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92
|
||||||
|
-98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22
|
||||||
|
-33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5
|
||||||
|
-13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247
|
||||||
|
-1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31
|
||||||
|
-14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83
|
||||||
|
-143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37
|
||||||
|
-28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121
|
||||||
|
-17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51
|
||||||
|
-112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4
|
||||||
|
-9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82
|
||||||
|
-123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34
|
||||||
|
-18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95
|
||||||
|
-62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17
|
||||||
|
-4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3
|
||||||
|
45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7
|
||||||
|
-7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10
|
||||||
|
23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15
|
||||||
|
72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52
|
||||||
|
32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12
|
||||||
|
24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106
|
||||||
|
27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534
|
||||||
|
10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13
|
||||||
|
200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60
|
||||||
|
-40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25
|
||||||
|
83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25
|
||||||
|
18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49
|
||||||
|
-3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0
|
||||||
|
-53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7
|
||||||
|
75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24
|
||||||
|
-46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96
|
||||||
|
-53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0
|
||||||
|
-7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85
|
||||||
|
-38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77
|
||||||
|
-25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91
|
||||||
|
-20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18
|
||||||
|
15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92
|
||||||
|
-113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115
|
||||||
|
301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46
|
||||||
|
89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23
|
||||||
|
15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z
|
||||||
|
m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7
|
||||||
|
-187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0
|
||||||
|
84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25
|
||||||
|
-32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17
|
||||||
|
-13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11
|
||||||
|
-14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49
|
||||||
|
-146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29
|
||||||
|
-104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48
|
||||||
|
22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0
|
||||||
|
10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40
|
||||||
|
16 57 18 38 52 41 99 11z" fill="#EF454A"/>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/favicon/atproto.com.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/favicon/bsky.app.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/favicon/syui.ai.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
1
public/icon/language.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
public/icon/user.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M320 312C386.3 312 440 258.3 440 192C440 125.7 386.3 72 320 72C253.7 72 200 125.7 200 192C200 258.3 253.7 312 320 312zM290.3 368C191.8 368 112 447.8 112 546.3C112 562.7 125.3 576 141.7 576L498.3 576C514.7 576 528 562.7 528 546.3C528 447.8 448.2 368 349.7 368L290.3 368z"/></svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
12
public/networks.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"bsky.social": {
|
||||||
|
"plc": "https://plc.directory",
|
||||||
|
"bsky": "https://public.api.bsky.app",
|
||||||
|
"web": "https://bsky.app"
|
||||||
|
},
|
||||||
|
"syu.is": {
|
||||||
|
"plc": "https://plc.syu.is",
|
||||||
|
"bsky": "https://bsky.syu.is",
|
||||||
|
"web": "https://syu.is"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
public/syui.svg
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
syui
|
||||||
|
</metadata>
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92
|
||||||
|
-98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22
|
||||||
|
-33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5
|
||||||
|
-13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247
|
||||||
|
-1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31
|
||||||
|
-14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83
|
||||||
|
-143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37
|
||||||
|
-28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121
|
||||||
|
-17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51
|
||||||
|
-112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4
|
||||||
|
-9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82
|
||||||
|
-123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34
|
||||||
|
-18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95
|
||||||
|
-62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17
|
||||||
|
-4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3
|
||||||
|
45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7
|
||||||
|
-7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10
|
||||||
|
23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15
|
||||||
|
72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52
|
||||||
|
32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12
|
||||||
|
24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106
|
||||||
|
27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534
|
||||||
|
10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13
|
||||||
|
200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60
|
||||||
|
-40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25
|
||||||
|
83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25
|
||||||
|
18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49
|
||||||
|
-3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0
|
||||||
|
-53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7
|
||||||
|
75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24
|
||||||
|
-46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96
|
||||||
|
-53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0
|
||||||
|
-7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85
|
||||||
|
-38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77
|
||||||
|
-25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91
|
||||||
|
-20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18
|
||||||
|
15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92
|
||||||
|
-113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115
|
||||||
|
301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46
|
||||||
|
89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23
|
||||||
|
15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z
|
||||||
|
m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7
|
||||||
|
-187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0
|
||||||
|
84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25
|
||||||
|
-32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17
|
||||||
|
-13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11
|
||||||
|
-14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49
|
||||||
|
-146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29
|
||||||
|
-104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48
|
||||||
|
22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0
|
||||||
|
10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40
|
||||||
|
16 57 18 38 52 41 99 11z" fill="#EF454A"/>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
111
readme.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# ailog
|
||||||
|
|
||||||
|
atproto blog cli
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ git clone https://git.syui.ai/ai/log
|
||||||
|
$ cd log
|
||||||
|
$ cat public/config.json
|
||||||
|
$ npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## cli
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ cargo build
|
||||||
|
$ ./target/debug/ailog
|
||||||
|
```
|
||||||
|
|
||||||
|
### login (l)
|
||||||
|
|
||||||
|
login to atproto pds.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog login <handle> -p <password> [-s <server>]
|
||||||
|
$ ailog l user.bsky.social -p mypassword
|
||||||
|
$ ailog l user.syu.is -p mypassword -s syu.is
|
||||||
|
```
|
||||||
|
|
||||||
|
### post (p)
|
||||||
|
|
||||||
|
post a record to collection.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog post <file> -c <collection> [-r <rkey>]
|
||||||
|
$ ailog p ./post.json -c ai.syui.log.post
|
||||||
|
$ ailog p ./post.json -c ai.syui.log.post -r 3abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### get (g)
|
||||||
|
|
||||||
|
get records from collection.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog get -c <collection> [-l <limit>]
|
||||||
|
$ ailog g -c ai.syui.log.post
|
||||||
|
$ ailog g -c ai.syui.log.post -l 20
|
||||||
|
```
|
||||||
|
|
||||||
|
### delete (d)
|
||||||
|
|
||||||
|
delete a record from collection.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog delete -c <collection> -r <rkey>
|
||||||
|
$ ailog d -c ai.syui.log.post -r 3abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### sync (s)
|
||||||
|
|
||||||
|
sync pds data to local content directory.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog sync [-o <output>]
|
||||||
|
$ ailog s
|
||||||
|
$ ailog s -o ./public/content
|
||||||
|
```
|
||||||
|
|
||||||
|
### lexicon
|
||||||
|
|
||||||
|
update lexicon schema.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog lexicon <file>
|
||||||
|
$ ailog lexicon ./lexicons/ai.syui.log.post.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog did syui.ai
|
||||||
|
did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||||
|
```
|
||||||
|
|
||||||
|
```txt
|
||||||
|
_lexicon.log.syui.ai txt "did=did:plc:uqzpqmrjnptsxezjx4xuh2mn"
|
||||||
|
```
|
||||||
|
|
||||||
|
### gen
|
||||||
|
|
||||||
|
generate lexicon code from atproto lexicon json files.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog gen [-i <input>] [-o <output>]
|
||||||
|
$ ailog gen
|
||||||
|
$ ailog gen -i ./repos/atproto/lexicons -o ./src/lexicons
|
||||||
|
```
|
||||||
|
|
||||||
|
### lang
|
||||||
|
|
||||||
|
translate content files using lms.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ailog lang <input> [-f <from>] [-t <to>]
|
||||||
|
$ ailog lang ./post.json
|
||||||
|
$ ailog lang ./public/content -f ja -t en
|
||||||
|
```
|
||||||
|
|
||||||
|
requires `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
||||||
|
TRANSLATE_MODEL=plamo-2-translate
|
||||||
|
```
|
||||||
97
src/commands/auth.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::token::{self, Session};
|
||||||
|
use crate::lexicons::{self, com_atproto_server};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CreateSessionRequest {
|
||||||
|
identifier: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CreateSessionResponse {
|
||||||
|
did: String,
|
||||||
|
handle: String,
|
||||||
|
access_jwt: String,
|
||||||
|
refresh_jwt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login to ATProto PDS
|
||||||
|
pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION);
|
||||||
|
|
||||||
|
let req = CreateSessionRequest {
|
||||||
|
identifier: handle.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Logging in to {} as {}...", pds, handle);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to send login request")?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Login failed: {} - {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_res: CreateSessionResponse = res.json().await?;
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
did: session_res.did,
|
||||||
|
handle: session_res.handle,
|
||||||
|
access_jwt: session_res.access_jwt,
|
||||||
|
refresh_jwt: session_res.refresh_jwt,
|
||||||
|
pds: Some(pds.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
token::save_session(&session)?;
|
||||||
|
println!("Logged in as {} ({})", session.handle, session.did);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh access token
|
||||||
|
pub async fn refresh_session() -> Result<Session> {
|
||||||
|
let session = token::load_session()?;
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", session.refresh_jwt))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to refresh session")?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Refresh failed: {} - {}. Try logging in again.", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_session: CreateSessionResponse = res.json().await?;
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
did: new_session.did,
|
||||||
|
handle: new_session.handle,
|
||||||
|
access_jwt: new_session.access_jwt,
|
||||||
|
refresh_jwt: new_session.refresh_jwt,
|
||||||
|
pds: Some(pds.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
token::save_session(&session)?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
34
src/commands/did.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::lexicons::{self, com_atproto_identity};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResolveHandleResponse {
|
||||||
|
did: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve handle to DID
|
||||||
|
pub async fn resolve(handle: &str, server: &str) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!(
|
||||||
|
"{}?handle={}",
|
||||||
|
lexicons::url(server, &com_atproto_identity::RESOLVE_HANDLE),
|
||||||
|
handle
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(&url).send().await?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Failed to resolve handle: {} - {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: ResolveHandleResponse = res.json().await
|
||||||
|
.context("Failed to parse response")?;
|
||||||
|
|
||||||
|
println!("{}", result.did);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
265
src/commands/gen.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Lexicon {
|
||||||
|
id: String,
|
||||||
|
defs: BTreeMap<String, LexiconDef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LexiconDef {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
def_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EndpointInfo {
|
||||||
|
nsid: String,
|
||||||
|
method: String, // GET or POST
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate lexicon code from ATProto lexicon JSON files
|
||||||
|
pub fn generate(input: &str, output: &str) -> Result<()> {
|
||||||
|
let input_path = Path::new(input);
|
||||||
|
|
||||||
|
if !input_path.exists() {
|
||||||
|
anyhow::bail!("Input directory does not exist: {}", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Scanning lexicons from: {}", input);
|
||||||
|
|
||||||
|
// Collect all endpoints grouped by namespace
|
||||||
|
let mut namespaces: BTreeMap<String, Vec<EndpointInfo>> = BTreeMap::new();
|
||||||
|
|
||||||
|
// Scan com/atproto directory
|
||||||
|
let atproto_path = input_path.join("com/atproto");
|
||||||
|
if atproto_path.exists() {
|
||||||
|
scan_namespace(&atproto_path, "com.atproto", &mut namespaces)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan app/bsky directory
|
||||||
|
let bsky_path = input_path.join("app/bsky");
|
||||||
|
if bsky_path.exists() {
|
||||||
|
scan_namespace(&bsky_path, "app.bsky", &mut namespaces)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Rust code
|
||||||
|
let rust_code = generate_rust_code(&namespaces);
|
||||||
|
let rust_output_path = Path::new(output).join("mod.rs");
|
||||||
|
fs::create_dir_all(output)?;
|
||||||
|
fs::write(&rust_output_path, &rust_code)?;
|
||||||
|
println!("Generated Rust: {}", rust_output_path.display());
|
||||||
|
|
||||||
|
// Generate TypeScript code
|
||||||
|
let ts_output = output.replace("src/lexicons", "src/web/lexicons");
|
||||||
|
let ts_code = generate_typescript_code(&namespaces);
|
||||||
|
let ts_output_path = Path::new(&ts_output).join("index.ts");
|
||||||
|
fs::create_dir_all(&ts_output)?;
|
||||||
|
fs::write(&ts_output_path, &ts_code)?;
|
||||||
|
println!("Generated TypeScript: {}", ts_output_path.display());
|
||||||
|
|
||||||
|
println!("Total namespaces: {}", namespaces.len());
|
||||||
|
let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum();
|
||||||
|
println!("Total endpoints: {}", total_endpoints);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_namespace(
|
||||||
|
base_path: &Path,
|
||||||
|
prefix: &str,
|
||||||
|
namespaces: &mut BTreeMap<String, Vec<EndpointInfo>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
for entry in fs::read_dir(base_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
let ns_name = path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.context("Invalid directory name")?;
|
||||||
|
|
||||||
|
let full_ns = format!("{}.{}", prefix, ns_name);
|
||||||
|
let mut endpoints = Vec::new();
|
||||||
|
|
||||||
|
// Scan JSON files in this namespace
|
||||||
|
for file_entry in fs::read_dir(&path)? {
|
||||||
|
let file_entry = file_entry?;
|
||||||
|
let file_path = file_entry.path();
|
||||||
|
|
||||||
|
if file_path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
if let Some(endpoint) = parse_lexicon_file(&file_path)? {
|
||||||
|
endpoints.push(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !endpoints.is_empty() {
|
||||||
|
endpoints.sort_by(|a, b| a.nsid.cmp(&b.nsid));
|
||||||
|
namespaces.insert(full_ns, endpoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_lexicon_file(path: &Path) -> Result<Option<EndpointInfo>> {
|
||||||
|
let content = fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("Failed to read: {}", path.display()))?;
|
||||||
|
|
||||||
|
let lexicon: Lexicon = serde_json::from_str(&content)
|
||||||
|
.with_context(|| format!("Failed to parse: {}", path.display()))?;
|
||||||
|
|
||||||
|
// Get the main definition type
|
||||||
|
let main_def = match lexicon.defs.get("main") {
|
||||||
|
Some(def) => def,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let method = match main_def.def_type.as_deref() {
|
||||||
|
Some("query") => "GET",
|
||||||
|
Some("procedure") => "POST",
|
||||||
|
Some("subscription") => return Ok(None), // Skip websocket subscriptions
|
||||||
|
_ => return Ok(None), // Skip records, tokens, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(EndpointInfo {
|
||||||
|
nsid: lexicon.id,
|
||||||
|
method: method.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_rust_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
|
||||||
|
let mut code = String::new();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
code.push_str("//! Auto-generated from ATProto lexicons\n");
|
||||||
|
code.push_str("//! Run `ailog gen` to regenerate\n");
|
||||||
|
code.push_str("//! Do not edit manually\n\n");
|
||||||
|
code.push_str("#![allow(dead_code)]\n\n");
|
||||||
|
|
||||||
|
// Endpoint struct
|
||||||
|
code.push_str("#[derive(Debug, Clone, Copy)]\n");
|
||||||
|
code.push_str("pub struct Endpoint {\n");
|
||||||
|
code.push_str(" pub nsid: &'static str,\n");
|
||||||
|
code.push_str(" pub method: &'static str,\n");
|
||||||
|
code.push_str("}\n\n");
|
||||||
|
|
||||||
|
// URL helper function
|
||||||
|
code.push_str("/// Build XRPC URL for an endpoint\n");
|
||||||
|
code.push_str("pub fn url(pds: &str, endpoint: &Endpoint) -> String {\n");
|
||||||
|
code.push_str(" format!(\"https://{}/xrpc/{}\", pds, endpoint.nsid)\n");
|
||||||
|
code.push_str("}\n\n");
|
||||||
|
|
||||||
|
// Generate modules for each namespace
|
||||||
|
for (ns, endpoints) in namespaces {
|
||||||
|
// Convert namespace to module name: com.atproto.repo -> com_atproto_repo
|
||||||
|
let mod_name = ns.replace('.', "_");
|
||||||
|
|
||||||
|
code.push_str(&format!("pub mod {} {{\n", mod_name));
|
||||||
|
code.push_str(" use super::Endpoint;\n\n");
|
||||||
|
|
||||||
|
for endpoint in endpoints {
|
||||||
|
// Extract the method name from NSID: com.atproto.repo.listRecords -> LIST_RECORDS
|
||||||
|
let method_name = endpoint.nsid
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&endpoint.nsid);
|
||||||
|
|
||||||
|
// Convert camelCase to SCREAMING_SNAKE_CASE
|
||||||
|
let const_name = to_screaming_snake_case(method_name);
|
||||||
|
|
||||||
|
code.push_str(&format!(
|
||||||
|
" pub const {}: Endpoint = Endpoint {{ nsid: \"{}\", method: \"{}\" }};\n",
|
||||||
|
const_name, endpoint.nsid, endpoint.method
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
code.push_str("}\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_typescript_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
|
||||||
|
let mut code = String::new();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
code.push_str("// Auto-generated from ATProto lexicons\n");
|
||||||
|
code.push_str("// Run `ailog gen` to regenerate\n");
|
||||||
|
code.push_str("// Do not edit manually\n\n");
|
||||||
|
|
||||||
|
// Endpoint type
|
||||||
|
code.push_str("export interface Endpoint {\n");
|
||||||
|
code.push_str(" nsid: string\n");
|
||||||
|
code.push_str(" method: 'GET' | 'POST'\n");
|
||||||
|
code.push_str("}\n\n");
|
||||||
|
|
||||||
|
// URL helper function
|
||||||
|
code.push_str("/** Build XRPC URL for an endpoint */\n");
|
||||||
|
code.push_str("export function xrpcUrl(pds: string, endpoint: Endpoint): string {\n");
|
||||||
|
code.push_str(" return `https://${pds}/xrpc/${endpoint.nsid}`\n");
|
||||||
|
code.push_str("}\n\n");
|
||||||
|
|
||||||
|
// Generate namespaces
|
||||||
|
for (ns, endpoints) in namespaces {
|
||||||
|
// Convert namespace to object name: com.atproto.repo -> comAtprotoRepo
|
||||||
|
let obj_name = to_camel_case(&ns.replace('.', "_"));
|
||||||
|
|
||||||
|
code.push_str(&format!("export const {} = {{\n", obj_name));
|
||||||
|
|
||||||
|
for endpoint in endpoints {
|
||||||
|
// Extract the method name from NSID: com.atproto.repo.listRecords -> listRecords
|
||||||
|
let method_name = endpoint.nsid
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&endpoint.nsid);
|
||||||
|
|
||||||
|
code.push_str(&format!(
|
||||||
|
" {}: {{ nsid: '{}', method: '{}' }} as Endpoint,\n",
|
||||||
|
method_name, endpoint.nsid, endpoint.method
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
code.push_str("} as const\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_screaming_snake_case(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
for (i, c) in s.chars().enumerate() {
|
||||||
|
if c.is_uppercase() && i > 0 {
|
||||||
|
result.push('_');
|
||||||
|
}
|
||||||
|
result.push(c.to_ascii_uppercase());
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_camel_case(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut capitalize_next = false;
|
||||||
|
|
||||||
|
for (i, c) in s.chars().enumerate() {
|
||||||
|
if c == '_' {
|
||||||
|
capitalize_next = true;
|
||||||
|
} else if capitalize_next {
|
||||||
|
result.push(c.to_ascii_uppercase());
|
||||||
|
capitalize_next = false;
|
||||||
|
} else if i == 0 {
|
||||||
|
result.push(c.to_ascii_lowercase());
|
||||||
|
} else {
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
10
src/commands/lang.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::lms;
|
||||||
|
|
||||||
|
/// Translate content files from one language to another
|
||||||
|
pub async fn translate(input: &str, from: &str, to: &str) -> Result<()> {
|
||||||
|
let path = Path::new(input);
|
||||||
|
lms::translate::run(path, from, to).await
|
||||||
|
}
|
||||||
6
src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod token;
|
||||||
|
pub mod post;
|
||||||
|
pub mod gen;
|
||||||
|
pub mod lang;
|
||||||
|
pub mod did;
|
||||||
351
src/commands/post.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use super::auth;
|
||||||
|
use crate::lexicons::{self, com_atproto_repo, com_atproto_identity};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct PutRecordRequest {
|
||||||
|
repo: String,
|
||||||
|
collection: String,
|
||||||
|
rkey: String,
|
||||||
|
record: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeleteRecordRequest {
|
||||||
|
repo: String,
|
||||||
|
collection: String,
|
||||||
|
rkey: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PutRecordResponse {
|
||||||
|
uri: String,
|
||||||
|
cid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ListRecordsResponse {
|
||||||
|
records: Vec<Record>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Record {
|
||||||
|
uri: String,
|
||||||
|
cid: String,
|
||||||
|
value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate TID (timestamp-based ID)
|
||||||
|
fn generate_tid() -> String {
|
||||||
|
const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
(0..13)
|
||||||
|
.map(|_| {
|
||||||
|
let idx = rng.gen_range(0..CHARSET.len());
|
||||||
|
CHARSET[idx] as char
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a record to ATProto
|
||||||
|
pub async fn put_record(file: &str, collection: &str, rkey: Option<&str>) -> Result<()> {
|
||||||
|
let session = auth::refresh_session().await?;
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
let content = fs::read_to_string(file)
|
||||||
|
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||||
|
let record: Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
|
||||||
|
|
||||||
|
let req = PutRecordRequest {
|
||||||
|
repo: session.did.clone(),
|
||||||
|
collection: collection.to_string(),
|
||||||
|
rkey: rkey.clone(),
|
||||||
|
record,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Posting to {} with rkey: {}", collection, rkey);
|
||||||
|
println!("{}", serde_json::to_string_pretty(&req)?);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Put record failed: {} - {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: PutRecordResponse = res.json().await?;
|
||||||
|
println!("Success!");
|
||||||
|
println!(" URI: {}", result.uri);
|
||||||
|
println!(" CID: {}", result.cid);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a lexicon schema
|
||||||
|
pub async fn put_lexicon(file: &str) -> Result<()> {
|
||||||
|
let session = auth::refresh_session().await?;
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
let content = fs::read_to_string(file)
|
||||||
|
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||||
|
let lexicon: Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
let lexicon_id = lexicon["id"]
|
||||||
|
.as_str()
|
||||||
|
.context("Lexicon file must have 'id' field")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
|
||||||
|
|
||||||
|
let req = PutRecordRequest {
|
||||||
|
repo: session.did.clone(),
|
||||||
|
collection: "com.atproto.lexicon.schema".to_string(),
|
||||||
|
rkey: lexicon_id.clone(),
|
||||||
|
record: lexicon,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Putting lexicon: {}", lexicon_id);
|
||||||
|
println!("{}", serde_json::to_string_pretty(&req)?);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Put lexicon failed: {} - {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: PutRecordResponse = res.json().await?;
|
||||||
|
println!("Success!");
|
||||||
|
println!(" URI: {}", result.uri);
|
||||||
|
println!(" CID: {}", result.cid);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get records from a collection
|
||||||
|
pub async fn get_records(collection: &str, limit: u32) -> Result<()> {
|
||||||
|
let session = auth::refresh_session().await?;
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let base_url = lexicons::url(pds, &com_atproto_repo::LIST_RECORDS);
|
||||||
|
let url = format!(
|
||||||
|
"{}?repo={}&collection={}&limit={}",
|
||||||
|
base_url, session.did, collection, limit
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Get records failed: {} - {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: ListRecordsResponse = res.json().await?;
|
||||||
|
|
||||||
|
println!("Found {} records in {}", result.records.len(), collection);
|
||||||
|
for record in &result.records {
|
||||||
|
println!("---");
|
||||||
|
println!("URI: {}", record.uri);
|
||||||
|
println!("CID: {}", record.cid);
|
||||||
|
println!("{}", serde_json::to_string_pretty(&record.value)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a record
|
||||||
|
pub async fn delete_record(collection: &str, rkey: &str) -> Result<()> {
|
||||||
|
let session = auth::refresh_session().await?;
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = lexicons::url(pds, &com_atproto_repo::DELETE_RECORD);
|
||||||
|
|
||||||
|
let req = DeleteRecordRequest {
|
||||||
|
repo: session.did.clone(),
|
||||||
|
collection: collection.to_string(),
|
||||||
|
rkey: rkey.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Deleting {} from {}", rkey, collection);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Delete failed: {} - {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Deleted successfully");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Config {
|
||||||
|
handle: String,
|
||||||
|
#[serde(default)]
|
||||||
|
collection: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DescribeRepoResponse {
|
||||||
|
did: String,
|
||||||
|
handle: String,
|
||||||
|
collections: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync PDS data to local content directory
|
||||||
|
pub async fn sync_to_local(output: &str) -> Result<()> {
|
||||||
|
let config_content = fs::read_to_string("public/config.json")
|
||||||
|
.context("config.json not found")?;
|
||||||
|
let config: Config = serde_json::from_str(&config_content)?;
|
||||||
|
|
||||||
|
println!("Syncing data for {}", config.handle);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Resolve handle to DID
|
||||||
|
let resolve_url = format!(
|
||||||
|
"{}?handle={}",
|
||||||
|
lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE),
|
||||||
|
config.handle
|
||||||
|
);
|
||||||
|
let res = client.get(&resolve_url).send().await?;
|
||||||
|
let resolve: serde_json::Value = res.json().await?;
|
||||||
|
let did = resolve["did"].as_str().context("Could not resolve handle")?;
|
||||||
|
|
||||||
|
println!("DID: {}", did);
|
||||||
|
|
||||||
|
// Get PDS from DID document
|
||||||
|
let plc_url = format!("https://plc.directory/{}", did);
|
||||||
|
let res = client.get(&plc_url).send().await?;
|
||||||
|
let did_doc: serde_json::Value = res.json().await?;
|
||||||
|
let pds = did_doc["service"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|services| {
|
||||||
|
services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
|
||||||
|
})
|
||||||
|
.and_then(|s| s["serviceEndpoint"].as_str())
|
||||||
|
.context("Could not find PDS")?;
|
||||||
|
|
||||||
|
println!("PDS: {}", pds);
|
||||||
|
|
||||||
|
// Remove https:// prefix for lexicons::url
|
||||||
|
let pds_host = pds.trim_start_matches("https://");
|
||||||
|
|
||||||
|
// Create output directory
|
||||||
|
let did_dir = format!("{}/{}", output, did);
|
||||||
|
fs::create_dir_all(&did_dir)?;
|
||||||
|
|
||||||
|
// 1. Sync describeRepo
|
||||||
|
let describe_url = format!(
|
||||||
|
"{}?repo={}",
|
||||||
|
lexicons::url(pds_host, &com_atproto_repo::DESCRIBE_REPO),
|
||||||
|
did
|
||||||
|
);
|
||||||
|
let res = client.get(&describe_url).send().await?;
|
||||||
|
let describe: DescribeRepoResponse = res.json().await?;
|
||||||
|
|
||||||
|
let describe_path = format!("{}/describe.json", did_dir);
|
||||||
|
let describe_json = serde_json::to_string_pretty(&serde_json::json!({
|
||||||
|
"did": describe.did,
|
||||||
|
"handle": describe.handle,
|
||||||
|
"collections": describe.collections,
|
||||||
|
}))?;
|
||||||
|
fs::write(&describe_path, &describe_json)?;
|
||||||
|
println!("Saved: {}", describe_path);
|
||||||
|
|
||||||
|
// 2. Sync profile
|
||||||
|
let profile_url = format!(
|
||||||
|
"{}?repo={}&collection=app.bsky.actor.profile&rkey=self",
|
||||||
|
lexicons::url(pds_host, &com_atproto_repo::GET_RECORD),
|
||||||
|
did
|
||||||
|
);
|
||||||
|
let res = client.get(&profile_url).send().await?;
|
||||||
|
if res.status().is_success() {
|
||||||
|
let profile: serde_json::Value = res.json().await?;
|
||||||
|
let profile_dir = format!("{}/app.bsky.actor.profile", did_dir);
|
||||||
|
fs::create_dir_all(&profile_dir)?;
|
||||||
|
let profile_path = format!("{}/self.json", profile_dir);
|
||||||
|
fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?;
|
||||||
|
println!("Saved: {}", profile_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sync collection records
|
||||||
|
let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post");
|
||||||
|
let records_url = format!(
|
||||||
|
"{}?repo={}&collection={}&limit=100",
|
||||||
|
lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS),
|
||||||
|
did, collection
|
||||||
|
);
|
||||||
|
let res = client.get(&records_url).send().await?;
|
||||||
|
if res.status().is_success() {
|
||||||
|
let list: ListRecordsResponse = res.json().await?;
|
||||||
|
let collection_dir = format!("{}/{}", did_dir, collection);
|
||||||
|
fs::create_dir_all(&collection_dir)?;
|
||||||
|
|
||||||
|
let mut rkeys: Vec<String> = Vec::new();
|
||||||
|
for record in &list.records {
|
||||||
|
let rkey = record.uri.split('/').last().unwrap_or("unknown");
|
||||||
|
rkeys.push(rkey.to_string());
|
||||||
|
let record_path = format!("{}/{}.json", collection_dir, rkey);
|
||||||
|
let record_json = serde_json::json!({
|
||||||
|
"uri": record.uri,
|
||||||
|
"cid": record.cid,
|
||||||
|
"value": record.value,
|
||||||
|
});
|
||||||
|
fs::write(&record_path, serde_json::to_string_pretty(&record_json)?)?;
|
||||||
|
println!("Saved: {}", record_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index.json with list of rkeys
|
||||||
|
let index_path = format!("{}/index.json", collection_dir);
|
||||||
|
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
|
||||||
|
println!("Saved: {}", index_path);
|
||||||
|
|
||||||
|
println!("Synced {} records from {}", list.records.len(), collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Sync complete!");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
46
src/commands/token.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Bundle ID for the application
|
||||||
|
pub const BUNDLE_ID: &str = "ai.syui.log";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Session {
|
||||||
|
pub did: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub access_jwt: String,
|
||||||
|
pub refresh_jwt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pds: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get token file path: ~/Library/Application Support/ai.syui.log/token.json
|
||||||
|
pub fn token_path() -> Result<PathBuf> {
|
||||||
|
let config_dir = dirs::config_dir()
|
||||||
|
.context("Could not find config directory")?
|
||||||
|
.join(BUNDLE_ID);
|
||||||
|
|
||||||
|
fs::create_dir_all(&config_dir)?;
|
||||||
|
Ok(config_dir.join("token.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load session from token file
|
||||||
|
pub fn load_session() -> Result<Session> {
|
||||||
|
let path = token_path()?;
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("Token file not found: {:?}. Run 'ailog login' first.", path))?;
|
||||||
|
let session: Session = serde_json::from_str(&content)?;
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save session to token file
|
||||||
|
pub fn save_session(session: &Session) -> Result<()> {
|
||||||
|
let path = token_path()?;
|
||||||
|
let content = serde_json::to_string_pretty(session)?;
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
println!("Token saved to {:?}", path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
303
src/lexicons/mod.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
//! Auto-generated from ATProto lexicons
|
||||||
|
//! Run `ailog gen` to regenerate
|
||||||
|
//! Do not edit manually
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Endpoint {
|
||||||
|
pub nsid: &'static str,
|
||||||
|
pub method: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build XRPC URL for an endpoint
|
||||||
|
pub fn url(pds: &str, endpoint: &Endpoint) -> String {
|
||||||
|
format!("https://{}/xrpc/{}", pds, endpoint.nsid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_actor {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.getPreferences", method: "GET" };
|
||||||
|
pub const GET_PROFILE: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfile", method: "GET" };
|
||||||
|
pub const GET_PROFILES: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfiles", method: "GET" };
|
||||||
|
pub const GET_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.actor.getSuggestions", method: "GET" };
|
||||||
|
pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.putPreferences", method: "POST" };
|
||||||
|
pub const SEARCH_ACTORS: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActors", method: "GET" };
|
||||||
|
pub const SEARCH_ACTORS_TYPEAHEAD: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActorsTypeahead", method: "GET" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_ageassurance {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const BEGIN: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.begin", method: "POST" };
|
||||||
|
pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getConfig", method: "GET" };
|
||||||
|
pub const GET_STATE: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getState", method: "GET" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_bookmark {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const CREATE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.createBookmark", method: "POST" };
|
||||||
|
pub const DELETE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.deleteBookmark", method: "POST" };
|
||||||
|
pub const GET_BOOKMARKS: Endpoint = Endpoint { nsid: "app.bsky.bookmark.getBookmarks", method: "GET" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_contact {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const DISMISS_MATCH: Endpoint = Endpoint { nsid: "app.bsky.contact.dismissMatch", method: "POST" };
|
||||||
|
pub const GET_MATCHES: Endpoint = Endpoint { nsid: "app.bsky.contact.getMatches", method: "GET" };
|
||||||
|
pub const GET_SYNC_STATUS: Endpoint = Endpoint { nsid: "app.bsky.contact.getSyncStatus", method: "GET" };
|
||||||
|
pub const IMPORT_CONTACTS: Endpoint = Endpoint { nsid: "app.bsky.contact.importContacts", method: "POST" };
|
||||||
|
pub const REMOVE_DATA: Endpoint = Endpoint { nsid: "app.bsky.contact.removeData", method: "POST" };
|
||||||
|
pub const SEND_NOTIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.sendNotification", method: "POST" };
|
||||||
|
pub const START_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.startPhoneVerification", method: "POST" };
|
||||||
|
pub const VERIFY_PHONE: Endpoint = Endpoint { nsid: "app.bsky.contact.verifyPhone", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_draft {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const CREATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.createDraft", method: "POST" };
|
||||||
|
pub const DELETE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.deleteDraft", method: "POST" };
|
||||||
|
pub const GET_DRAFTS: Endpoint = Endpoint { nsid: "app.bsky.draft.getDrafts", method: "GET" };
|
||||||
|
pub const UPDATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.updateDraft", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_feed {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const DESCRIBE_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.describeFeedGenerator", method: "GET" };
|
||||||
|
pub const GET_ACTOR_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorFeeds", method: "GET" };
|
||||||
|
pub const GET_ACTOR_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorLikes", method: "GET" };
|
||||||
|
pub const GET_AUTHOR_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getAuthorFeed", method: "GET" };
|
||||||
|
pub const GET_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeed", method: "GET" };
|
||||||
|
pub const GET_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerator", method: "GET" };
|
||||||
|
pub const GET_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerators", method: "GET" };
|
||||||
|
pub const GET_FEED_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedSkeleton", method: "GET" };
|
||||||
|
pub const GET_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getLikes", method: "GET" };
|
||||||
|
pub const GET_LIST_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getListFeed", method: "GET" };
|
||||||
|
pub const GET_POST_THREAD: Endpoint = Endpoint { nsid: "app.bsky.feed.getPostThread", method: "GET" };
|
||||||
|
pub const GET_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.getPosts", method: "GET" };
|
||||||
|
pub const GET_QUOTES: Endpoint = Endpoint { nsid: "app.bsky.feed.getQuotes", method: "GET" };
|
||||||
|
pub const GET_REPOSTED_BY: Endpoint = Endpoint { nsid: "app.bsky.feed.getRepostedBy", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getSuggestedFeeds", method: "GET" };
|
||||||
|
pub const GET_TIMELINE: Endpoint = Endpoint { nsid: "app.bsky.feed.getTimeline", method: "GET" };
|
||||||
|
pub const SEARCH_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.searchPosts", method: "GET" };
|
||||||
|
pub const SEND_INTERACTIONS: Endpoint = Endpoint { nsid: "app.bsky.feed.sendInteractions", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_graph {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_ACTOR_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getActorStarterPacks", method: "GET" };
|
||||||
|
pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getBlocks", method: "GET" };
|
||||||
|
pub const GET_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollowers", method: "GET" };
|
||||||
|
pub const GET_FOLLOWS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollows", method: "GET" };
|
||||||
|
pub const GET_KNOWN_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getKnownFollowers", method: "GET" };
|
||||||
|
pub const GET_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.getList", method: "GET" };
|
||||||
|
pub const GET_LIST_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getListBlocks", method: "GET" };
|
||||||
|
pub const GET_LIST_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getListMutes", method: "GET" };
|
||||||
|
pub const GET_LISTS: Endpoint = Endpoint { nsid: "app.bsky.graph.getLists", method: "GET" };
|
||||||
|
pub const GET_LISTS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getListsWithMembership", method: "GET" };
|
||||||
|
pub const GET_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getMutes", method: "GET" };
|
||||||
|
pub const GET_RELATIONSHIPS: Endpoint = Endpoint { nsid: "app.bsky.graph.getRelationships", method: "GET" };
|
||||||
|
pub const GET_STARTER_PACK: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPack", method: "GET" };
|
||||||
|
pub const GET_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacks", method: "GET" };
|
||||||
|
pub const GET_STARTER_PACKS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacksWithMembership", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_FOLLOWS_BY_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.getSuggestedFollowsByActor", method: "GET" };
|
||||||
|
pub const MUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActor", method: "POST" };
|
||||||
|
pub const MUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActorList", method: "POST" };
|
||||||
|
pub const MUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.muteThread", method: "POST" };
|
||||||
|
pub const SEARCH_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.searchStarterPacks", method: "GET" };
|
||||||
|
pub const UNMUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActor", method: "POST" };
|
||||||
|
pub const UNMUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActorList", method: "POST" };
|
||||||
|
pub const UNMUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteThread", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_labeler {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_SERVICES: Endpoint = Endpoint { nsid: "app.bsky.labeler.getServices", method: "GET" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_notification {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.getPreferences", method: "GET" };
|
||||||
|
pub const GET_UNREAD_COUNT: Endpoint = Endpoint { nsid: "app.bsky.notification.getUnreadCount", method: "GET" };
|
||||||
|
pub const LIST_ACTIVITY_SUBSCRIPTIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listActivitySubscriptions", method: "GET" };
|
||||||
|
pub const LIST_NOTIFICATIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listNotifications", method: "GET" };
|
||||||
|
pub const PUT_ACTIVITY_SUBSCRIPTION: Endpoint = Endpoint { nsid: "app.bsky.notification.putActivitySubscription", method: "POST" };
|
||||||
|
pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferences", method: "POST" };
|
||||||
|
pub const PUT_PREFERENCES_V2: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferencesV2", method: "POST" };
|
||||||
|
pub const REGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.registerPush", method: "POST" };
|
||||||
|
pub const UNREGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.unregisterPush", method: "POST" };
|
||||||
|
pub const UPDATE_SEEN: Endpoint = Endpoint { nsid: "app.bsky.notification.updateSeen", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_unspecced {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_AGE_ASSURANCE_STATE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getAgeAssuranceState", method: "GET" };
|
||||||
|
pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getConfig", method: "GET" };
|
||||||
|
pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacks", method: "GET" };
|
||||||
|
pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton", method: "GET" };
|
||||||
|
pub const GET_POPULAR_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPopularFeedGenerators", method: "GET" };
|
||||||
|
pub const GET_POST_THREAD_OTHER_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadOtherV2", method: "GET" };
|
||||||
|
pub const GET_POST_THREAD_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadV2", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeeds", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_FEEDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeedsSkeleton", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacks", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacksSkeleton", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_USERS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsers", method: "GET" };
|
||||||
|
pub const GET_SUGGESTED_USERS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsersSkeleton", method: "GET" };
|
||||||
|
pub const GET_SUGGESTIONS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestionsSkeleton", method: "GET" };
|
||||||
|
pub const GET_TAGGED_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTaggedSuggestions", method: "GET" };
|
||||||
|
pub const GET_TRENDING_TOPICS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendingTopics", method: "GET" };
|
||||||
|
pub const GET_TRENDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrends", method: "GET" };
|
||||||
|
pub const GET_TRENDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendsSkeleton", method: "GET" };
|
||||||
|
pub const INIT_AGE_ASSURANCE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.initAgeAssurance", method: "POST" };
|
||||||
|
pub const SEARCH_ACTORS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchActorsSkeleton", method: "GET" };
|
||||||
|
pub const SEARCH_POSTS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchPostsSkeleton", method: "GET" };
|
||||||
|
pub const SEARCH_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchStarterPacksSkeleton", method: "GET" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod app_bsky_video {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_JOB_STATUS: Endpoint = Endpoint { nsid: "app.bsky.video.getJobStatus", method: "GET" };
|
||||||
|
pub const GET_UPLOAD_LIMITS: Endpoint = Endpoint { nsid: "app.bsky.video.getUploadLimits", method: "GET" };
|
||||||
|
pub const UPLOAD_VIDEO: Endpoint = Endpoint { nsid: "app.bsky.video.uploadVideo", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_admin {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.admin.deleteAccount", method: "POST" };
|
||||||
|
pub const DISABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableAccountInvites", method: "POST" };
|
||||||
|
pub const DISABLE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableInviteCodes", method: "POST" };
|
||||||
|
pub const ENABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.enableAccountInvites", method: "POST" };
|
||||||
|
pub const GET_ACCOUNT_INFO: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfo", method: "GET" };
|
||||||
|
pub const GET_ACCOUNT_INFOS: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfos", method: "GET" };
|
||||||
|
pub const GET_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.getInviteCodes", method: "GET" };
|
||||||
|
pub const GET_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.getSubjectStatus", method: "GET" };
|
||||||
|
pub const SEARCH_ACCOUNTS: Endpoint = Endpoint { nsid: "com.atproto.admin.searchAccounts", method: "GET" };
|
||||||
|
pub const SEND_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.sendEmail", method: "POST" };
|
||||||
|
pub const UPDATE_ACCOUNT_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountEmail", method: "POST" };
|
||||||
|
pub const UPDATE_ACCOUNT_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountHandle", method: "POST" };
|
||||||
|
pub const UPDATE_ACCOUNT_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountPassword", method: "POST" };
|
||||||
|
pub const UPDATE_ACCOUNT_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountSigningKey", method: "POST" };
|
||||||
|
pub const UPDATE_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.updateSubjectStatus", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_identity {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_RECOMMENDED_DID_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.identity.getRecommendedDidCredentials", method: "GET" };
|
||||||
|
pub const REFRESH_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.refreshIdentity", method: "POST" };
|
||||||
|
pub const REQUEST_PLC_OPERATION_SIGNATURE: Endpoint = Endpoint { nsid: "com.atproto.identity.requestPlcOperationSignature", method: "POST" };
|
||||||
|
pub const RESOLVE_DID: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveDid", method: "GET" };
|
||||||
|
pub const RESOLVE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveHandle", method: "GET" };
|
||||||
|
pub const RESOLVE_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveIdentity", method: "GET" };
|
||||||
|
pub const SIGN_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.signPlcOperation", method: "POST" };
|
||||||
|
pub const SUBMIT_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.submitPlcOperation", method: "POST" };
|
||||||
|
pub const UPDATE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.updateHandle", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_label {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const QUERY_LABELS: Endpoint = Endpoint { nsid: "com.atproto.label.queryLabels", method: "GET" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_lexicon {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const RESOLVE_LEXICON: Endpoint = Endpoint { nsid: "com.atproto.lexicon.resolveLexicon", method: "GET" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_moderation {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const CREATE_REPORT: Endpoint = Endpoint { nsid: "com.atproto.moderation.createReport", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_repo {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const APPLY_WRITES: Endpoint = Endpoint { nsid: "com.atproto.repo.applyWrites", method: "POST" };
|
||||||
|
pub const CREATE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.createRecord", method: "POST" };
|
||||||
|
pub const DELETE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.deleteRecord", method: "POST" };
|
||||||
|
pub const DESCRIBE_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.describeRepo", method: "GET" };
|
||||||
|
pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.getRecord", method: "GET" };
|
||||||
|
pub const IMPORT_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.importRepo", method: "POST" };
|
||||||
|
pub const LIST_MISSING_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.repo.listMissingBlobs", method: "GET" };
|
||||||
|
pub const LIST_RECORDS: Endpoint = Endpoint { nsid: "com.atproto.repo.listRecords", method: "GET" };
|
||||||
|
pub const PUT_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.putRecord", method: "POST" };
|
||||||
|
pub const UPLOAD_BLOB: Endpoint = Endpoint { nsid: "com.atproto.repo.uploadBlob", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_server {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const ACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.activateAccount", method: "POST" };
|
||||||
|
pub const CHECK_ACCOUNT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.server.checkAccountStatus", method: "GET" };
|
||||||
|
pub const CONFIRM_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.confirmEmail", method: "POST" };
|
||||||
|
pub const CREATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.createAccount", method: "POST" };
|
||||||
|
pub const CREATE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.createAppPassword", method: "POST" };
|
||||||
|
pub const CREATE_INVITE_CODE: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCode", method: "POST" };
|
||||||
|
pub const CREATE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCodes", method: "POST" };
|
||||||
|
pub const CREATE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.createSession", method: "POST" };
|
||||||
|
pub const DEACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deactivateAccount", method: "POST" };
|
||||||
|
pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deleteAccount", method: "POST" };
|
||||||
|
pub const DELETE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.deleteSession", method: "POST" };
|
||||||
|
pub const DESCRIBE_SERVER: Endpoint = Endpoint { nsid: "com.atproto.server.describeServer", method: "GET" };
|
||||||
|
pub const GET_ACCOUNT_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.getAccountInviteCodes", method: "GET" };
|
||||||
|
pub const GET_SERVICE_AUTH: Endpoint = Endpoint { nsid: "com.atproto.server.getServiceAuth", method: "GET" };
|
||||||
|
pub const GET_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.getSession", method: "GET" };
|
||||||
|
pub const LIST_APP_PASSWORDS: Endpoint = Endpoint { nsid: "com.atproto.server.listAppPasswords", method: "GET" };
|
||||||
|
pub const REFRESH_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.refreshSession", method: "POST" };
|
||||||
|
pub const REQUEST_ACCOUNT_DELETE: Endpoint = Endpoint { nsid: "com.atproto.server.requestAccountDelete", method: "POST" };
|
||||||
|
pub const REQUEST_EMAIL_CONFIRMATION: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailConfirmation", method: "POST" };
|
||||||
|
pub const REQUEST_EMAIL_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailUpdate", method: "POST" };
|
||||||
|
pub const REQUEST_PASSWORD_RESET: Endpoint = Endpoint { nsid: "com.atproto.server.requestPasswordReset", method: "POST" };
|
||||||
|
pub const RESERVE_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.server.reserveSigningKey", method: "POST" };
|
||||||
|
pub const RESET_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.resetPassword", method: "POST" };
|
||||||
|
pub const REVOKE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.revokeAppPassword", method: "POST" };
|
||||||
|
pub const UPDATE_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.updateEmail", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_sync {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const GET_BLOB: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlob", method: "GET" };
|
||||||
|
pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlocks", method: "GET" };
|
||||||
|
pub const GET_CHECKOUT: Endpoint = Endpoint { nsid: "com.atproto.sync.getCheckout", method: "GET" };
|
||||||
|
pub const GET_HEAD: Endpoint = Endpoint { nsid: "com.atproto.sync.getHead", method: "GET" };
|
||||||
|
pub const GET_HOST_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getHostStatus", method: "GET" };
|
||||||
|
pub const GET_LATEST_COMMIT: Endpoint = Endpoint { nsid: "com.atproto.sync.getLatestCommit", method: "GET" };
|
||||||
|
pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.sync.getRecord", method: "GET" };
|
||||||
|
pub const GET_REPO: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepo", method: "GET" };
|
||||||
|
pub const GET_REPO_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepoStatus", method: "GET" };
|
||||||
|
pub const LIST_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.sync.listBlobs", method: "GET" };
|
||||||
|
pub const LIST_HOSTS: Endpoint = Endpoint { nsid: "com.atproto.sync.listHosts", method: "GET" };
|
||||||
|
pub const LIST_REPOS: Endpoint = Endpoint { nsid: "com.atproto.sync.listRepos", method: "GET" };
|
||||||
|
pub const LIST_REPOS_BY_COLLECTION: Endpoint = Endpoint { nsid: "com.atproto.sync.listReposByCollection", method: "GET" };
|
||||||
|
pub const NOTIFY_OF_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.sync.notifyOfUpdate", method: "POST" };
|
||||||
|
pub const REQUEST_CRAWL: Endpoint = Endpoint { nsid: "com.atproto.sync.requestCrawl", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod com_atproto_temp {
|
||||||
|
use super::Endpoint;
|
||||||
|
|
||||||
|
pub const ADD_RESERVED_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.temp.addReservedHandle", method: "POST" };
|
||||||
|
pub const CHECK_HANDLE_AVAILABILITY: Endpoint = Endpoint { nsid: "com.atproto.temp.checkHandleAvailability", method: "GET" };
|
||||||
|
pub const CHECK_SIGNUP_QUEUE: Endpoint = Endpoint { nsid: "com.atproto.temp.checkSignupQueue", method: "GET" };
|
||||||
|
pub const DEREFERENCE_SCOPE: Endpoint = Endpoint { nsid: "com.atproto.temp.dereferenceScope", method: "GET" };
|
||||||
|
pub const FETCH_LABELS: Endpoint = Endpoint { nsid: "com.atproto.temp.fetchLabels", method: "GET" };
|
||||||
|
pub const REQUEST_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "com.atproto.temp.requestPhoneVerification", method: "POST" };
|
||||||
|
pub const REVOKE_ACCOUNT_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.temp.revokeAccountCredentials", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
1
src/lms/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod translate;
|
||||||
244
src/lms/translate.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChatMessage {
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChatRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatChoice {
|
||||||
|
message: ChatMessageResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatMessageResponse {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatResponse {
|
||||||
|
choices: Vec<ChatChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a file or folder
|
||||||
|
pub async fn run(input: &Path, from: &str, to: &str) -> Result<()> {
|
||||||
|
if input.is_dir() {
|
||||||
|
translate_folder(input, from, to).await
|
||||||
|
} else {
|
||||||
|
translate_file(input, from, to).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate_text(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
model: &str,
|
||||||
|
text: &str,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let from_lang = lang_name(from);
|
||||||
|
let to_lang = lang_name(to);
|
||||||
|
|
||||||
|
let system_content = "<|plamo:op|>dataset\ntranslation".to_string();
|
||||||
|
let user_content = format!(
|
||||||
|
"<|plamo:op|>input lang={}\n{}\n<|plamo:op|>output lang={}",
|
||||||
|
from_lang, text, to_lang
|
||||||
|
);
|
||||||
|
|
||||||
|
let req = ChatRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: system_content,
|
||||||
|
},
|
||||||
|
ChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: user_content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = client.post(url).json(&req).send().await?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await?;
|
||||||
|
return Err(anyhow!("Translation failed ({}): {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_res: ChatResponse = res.json().await?;
|
||||||
|
chat_res
|
||||||
|
.choices
|
||||||
|
.first()
|
||||||
|
.map(|c| c.message.content.trim().to_string())
|
||||||
|
.ok_or_else(|| anyhow!("No translation result"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate_file(input: &Path, from: &str, to: &str) -> Result<()> {
|
||||||
|
let translate_url =
|
||||||
|
env::var("TRANSLATE_URL").unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string());
|
||||||
|
let model =
|
||||||
|
env::var("TRANSLATE_MODEL").unwrap_or_else(|_| "plamo-2-translate".to_string());
|
||||||
|
|
||||||
|
println!("Translating: {}", input.display());
|
||||||
|
|
||||||
|
// Read input JSON
|
||||||
|
let content = fs::read_to_string(input)?;
|
||||||
|
let mut record: serde_json::Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
// Handle both direct format and wrapped format (with "value" field)
|
||||||
|
let value = if record.get("value").is_some() {
|
||||||
|
record.get_mut("value").unwrap()
|
||||||
|
} else {
|
||||||
|
&mut record
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if already translated
|
||||||
|
if value
|
||||||
|
.get("translations")
|
||||||
|
.and_then(|t| t.get(to))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
println!(" Skipped (already has {} translation)", to);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("{}/chat/completions", translate_url);
|
||||||
|
|
||||||
|
// Translate title if exists
|
||||||
|
let translated_title = if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
|
||||||
|
if !title.is_empty() {
|
||||||
|
Some(translate_text(&client, &url, &model, title, from, to).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get and translate content
|
||||||
|
let text = value
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow!("No 'content' field in JSON"))?;
|
||||||
|
|
||||||
|
let translated_content = translate_text(&client, &url, &model, text, from, to).await?;
|
||||||
|
|
||||||
|
// Add translation to value
|
||||||
|
let translations = value
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| anyhow!("Invalid JSON"))?
|
||||||
|
.entry("translations")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
let mut translation_entry = serde_json::json!({
|
||||||
|
"content": translated_content
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(title) = translated_title {
|
||||||
|
translation_entry
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("title".to_string(), serde_json::json!(title));
|
||||||
|
}
|
||||||
|
|
||||||
|
translations
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| anyhow!("Invalid translations field"))?
|
||||||
|
.insert(to.to_string(), translation_entry);
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
let output = serde_json::to_string_pretty(&record)?;
|
||||||
|
fs::write(input, output)?;
|
||||||
|
|
||||||
|
println!(" OK");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_json_files(dir: &Path, files: &mut Vec<std::path::PathBuf>) -> Result<()> {
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_json_files(&path, files)?;
|
||||||
|
} else if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
// Skip non-post files (describe.json, self.json, index.json)
|
||||||
|
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
if filename != "describe.json" && filename != "self.json" && filename != "index.json" {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate_folder(dir: &Path, from: &str, to: &str) -> Result<()> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_json_files(dir, &mut files)?;
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
println!("Translating {} files ({} -> {})", files.len(), from, to);
|
||||||
|
|
||||||
|
let mut success = 0;
|
||||||
|
let mut skipped = 0;
|
||||||
|
let mut failed = 0;
|
||||||
|
|
||||||
|
for path in &files {
|
||||||
|
match translate_file(path, from, to).await {
|
||||||
|
Ok(_) => {
|
||||||
|
// Check if it was actually translated or skipped
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let record: serde_json::Value = serde_json::from_str(&content)?;
|
||||||
|
let value = record.get("value").unwrap_or(&record);
|
||||||
|
if value
|
||||||
|
.get("translations")
|
||||||
|
.and_then(|t| t.get(to))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
success += 1;
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ERROR {}: {}", path.display(), e);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\nDone: {} translated, {} skipped, {} failed",
|
||||||
|
success, skipped, failed
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lang_name(code: &str) -> &str {
|
||||||
|
match code {
|
||||||
|
"ja" => "Japanese",
|
||||||
|
"en" => "English",
|
||||||
|
"zh" => "Chinese",
|
||||||
|
"ko" => "Korean",
|
||||||
|
"fr" => "French",
|
||||||
|
"de" => "German",
|
||||||
|
"es" => "Spanish",
|
||||||
|
_ => code,
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/main.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
mod commands;
|
||||||
|
mod lexicons;
|
||||||
|
mod lms;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "ailog")]
|
||||||
|
#[command(about = "ATProto blog CLI")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Login to ATProto PDS
|
||||||
|
#[command(alias = "l")]
|
||||||
|
Login {
|
||||||
|
/// Handle (e.g., user.bsky.social)
|
||||||
|
handle: String,
|
||||||
|
/// Password
|
||||||
|
#[arg(short, long)]
|
||||||
|
password: String,
|
||||||
|
/// PDS server
|
||||||
|
#[arg(short, long, default_value = "bsky.social")]
|
||||||
|
server: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Update lexicon schema
|
||||||
|
Lexicon {
|
||||||
|
/// Lexicon JSON file
|
||||||
|
file: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Post a record
|
||||||
|
#[command(alias = "p")]
|
||||||
|
Post {
|
||||||
|
/// Record JSON file
|
||||||
|
file: String,
|
||||||
|
/// Collection (e.g., ai.syui.log.post)
|
||||||
|
#[arg(short, long)]
|
||||||
|
collection: String,
|
||||||
|
/// Record key (auto-generated if not provided)
|
||||||
|
#[arg(short, long)]
|
||||||
|
rkey: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Get records from collection
|
||||||
|
#[command(alias = "g")]
|
||||||
|
Get {
|
||||||
|
/// Collection (e.g., ai.syui.log.post)
|
||||||
|
#[arg(short, long)]
|
||||||
|
collection: String,
|
||||||
|
/// Limit
|
||||||
|
#[arg(short, long, default_value = "10")]
|
||||||
|
limit: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Delete a record
|
||||||
|
#[command(alias = "d")]
|
||||||
|
Delete {
|
||||||
|
/// Collection (e.g., ai.syui.log.post)
|
||||||
|
#[arg(short, long)]
|
||||||
|
collection: String,
|
||||||
|
/// Record key
|
||||||
|
#[arg(short, long)]
|
||||||
|
rkey: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Sync PDS data to local content directory
|
||||||
|
#[command(alias = "s")]
|
||||||
|
Sync {
|
||||||
|
/// Output directory
|
||||||
|
#[arg(short, long, default_value = "public/content")]
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Generate lexicon Rust code from ATProto lexicon JSON files
|
||||||
|
Gen {
|
||||||
|
/// Input directory containing lexicon JSON files
|
||||||
|
#[arg(short, long, default_value = "./repos/atproto/lexicons")]
|
||||||
|
input: String,
|
||||||
|
/// Output directory for generated Rust code
|
||||||
|
#[arg(short, long, default_value = "./src/lexicons")]
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Translate content files
|
||||||
|
Lang {
|
||||||
|
/// Input file or directory
|
||||||
|
input: String,
|
||||||
|
/// Source language
|
||||||
|
#[arg(short, long, default_value = "ja")]
|
||||||
|
from: String,
|
||||||
|
/// Target language
|
||||||
|
#[arg(short, long, default_value = "en")]
|
||||||
|
to: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Resolve handle to DID
|
||||||
|
Did {
|
||||||
|
/// Handle (e.g., syui.ai)
|
||||||
|
handle: String,
|
||||||
|
/// Server
|
||||||
|
#[arg(short, long, default_value = "bsky.social")]
|
||||||
|
server: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// Load .env file if exists
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Login { handle, password, server } => {
|
||||||
|
commands::auth::login(&handle, &password, &server).await?;
|
||||||
|
}
|
||||||
|
Commands::Lexicon { file } => {
|
||||||
|
commands::post::put_lexicon(&file).await?;
|
||||||
|
}
|
||||||
|
Commands::Post { file, collection, rkey } => {
|
||||||
|
commands::post::put_record(&file, &collection, rkey.as_deref()).await?;
|
||||||
|
}
|
||||||
|
Commands::Get { collection, limit } => {
|
||||||
|
commands::post::get_records(&collection, limit).await?;
|
||||||
|
}
|
||||||
|
Commands::Delete { collection, rkey } => {
|
||||||
|
commands::post::delete_record(&collection, &rkey).await?;
|
||||||
|
}
|
||||||
|
Commands::Sync { output } => {
|
||||||
|
commands::post::sync_to_local(&output).await?;
|
||||||
|
}
|
||||||
|
Commands::Gen { input, output } => {
|
||||||
|
commands::gen::generate(&input, &output)?;
|
||||||
|
}
|
||||||
|
Commands::Lang { input, from, to } => {
|
||||||
|
commands::lang::translate(&input, &from, &to).await?;
|
||||||
|
}
|
||||||
|
Commands::Did { handle, server } => {
|
||||||
|
commands::did::resolve(&handle, &server).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
222
src/web/components/browser.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// AT-Browser: Server info and collection hierarchy
|
||||||
|
|
||||||
|
// Group collections by service domain
|
||||||
|
function groupCollectionsByService(collections: string[]): Map<string, string[]> {
|
||||||
|
const groups = new Map<string, string[]>()
|
||||||
|
|
||||||
|
for (const collection of collections) {
|
||||||
|
// Extract service from collection (e.g., "app.bsky.feed.post" -> "bsky.app")
|
||||||
|
const parts = collection.split('.')
|
||||||
|
let service: string
|
||||||
|
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// Reverse first two parts: app.bsky -> bsky.app, ai.syui -> syui.ai
|
||||||
|
service = `${parts[1]}.${parts[0]}`
|
||||||
|
} else {
|
||||||
|
service = collection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups.has(service)) {
|
||||||
|
groups.set(service, [])
|
||||||
|
}
|
||||||
|
groups.get(service)!.push(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local favicon mappings
|
||||||
|
const localFavicons: Record<string, string> = {
|
||||||
|
'syui.ai': '/favicon/syui.ai.png',
|
||||||
|
'bsky.app': '/favicon/bsky.app.png',
|
||||||
|
'atproto.com': '/favicon/atproto.com.png',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get favicon URL for service
|
||||||
|
function getFaviconUrl(service: string): string {
|
||||||
|
if (localFavicons[service]) {
|
||||||
|
return localFavicons[service]
|
||||||
|
}
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${service}&sz=32`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render compact collection buttons for user page (horizontal)
|
||||||
|
export function renderCollectionButtons(collections: string[], handle: string): string {
|
||||||
|
if (collections.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupCollectionsByService(collections)
|
||||||
|
|
||||||
|
const buttons = Array.from(groups.keys()).map(service => {
|
||||||
|
const favicon = getFaviconUrl(service)
|
||||||
|
return `
|
||||||
|
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="collection-btn" title="${service}">
|
||||||
|
<img src="${favicon}" alt="" class="collection-btn-icon" onerror="this.style.display='none'">
|
||||||
|
<span>${service}</span>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<div class="collection-buttons">${buttons}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render server info section (for AT-Browser)
|
||||||
|
export function renderServerInfo(did: string, pds: string | null): string {
|
||||||
|
return `
|
||||||
|
<div class="server-info">
|
||||||
|
<h3>Server</h3>
|
||||||
|
<dl class="server-details">
|
||||||
|
<div class="server-row">
|
||||||
|
<dt>PDS</dt>
|
||||||
|
<dd>${pds || 'Unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="server-row">
|
||||||
|
<dt>DID</dt>
|
||||||
|
<dd>${did}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render service list (grouped collections) for AT-Browser
|
||||||
|
export function renderServiceList(collections: string[], handle: string): string {
|
||||||
|
if (collections.length === 0) {
|
||||||
|
return '<p class="no-collections">No collections found.</p>'
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupCollectionsByService(collections)
|
||||||
|
|
||||||
|
const items = Array.from(groups.entries()).map(([service, cols]) => {
|
||||||
|
const favicon = getFaviconUrl(service)
|
||||||
|
const count = cols.length
|
||||||
|
|
||||||
|
return `
|
||||||
|
<li class="service-list-item">
|
||||||
|
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="service-list-link">
|
||||||
|
<img src="${favicon}" alt="" class="service-list-favicon" onerror="this.style.display='none'">
|
||||||
|
<span class="service-list-name">${service}</span>
|
||||||
|
<span class="service-list-count">${count}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="services-list">
|
||||||
|
<h3>Collections</h3>
|
||||||
|
<ul class="service-list">${items}</ul>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render collections for a specific service
|
||||||
|
export function renderCollectionList(
|
||||||
|
collections: string[],
|
||||||
|
handle: string,
|
||||||
|
service: string
|
||||||
|
): string {
|
||||||
|
const favicon = getFaviconUrl(service)
|
||||||
|
|
||||||
|
const items = collections.map(collection => {
|
||||||
|
return `
|
||||||
|
<li class="collection-item">
|
||||||
|
<a href="/@${handle}/at/collection/${collection}" class="collection-link">
|
||||||
|
<span class="collection-nsid">${collection}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="collections">
|
||||||
|
<h3 class="collection-header">
|
||||||
|
<img src="${favicon}" alt="" class="collection-header-favicon" onerror="this.style.display='none'">
|
||||||
|
${service}
|
||||||
|
</h3>
|
||||||
|
<ul class="collection-list">${items}</ul>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render records list
|
||||||
|
export function renderRecordList(
|
||||||
|
records: { uri: string; cid: string; value: unknown }[],
|
||||||
|
handle: string,
|
||||||
|
collection: string
|
||||||
|
): string {
|
||||||
|
if (records.length === 0) {
|
||||||
|
return '<p class="no-records">No records found.</p>'
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = records.map(record => {
|
||||||
|
const rkey = record.uri.split('/').pop() || ''
|
||||||
|
const value = record.value as Record<string, unknown>
|
||||||
|
const preview = getRecordPreview(value)
|
||||||
|
|
||||||
|
return `
|
||||||
|
<li class="record-item">
|
||||||
|
<a href="/@${handle}/at/collection/${collection}/${rkey}" class="record-link">
|
||||||
|
<span class="record-rkey">${rkey}</span>
|
||||||
|
<span class="record-preview">${escapeHtml(preview)}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="records">
|
||||||
|
<h3>${collection}</h3>
|
||||||
|
<p class="record-count">${records.length} records</p>
|
||||||
|
<ul class="record-list">${items}</ul>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render single record detail
|
||||||
|
export function renderRecordDetail(
|
||||||
|
record: { uri: string; cid: string; value: unknown },
|
||||||
|
collection: string,
|
||||||
|
isOwner: boolean = false
|
||||||
|
): string {
|
||||||
|
const rkey = record.uri.split('/').pop() || ''
|
||||||
|
const deleteBtn = isOwner ? `
|
||||||
|
<button type="button" class="record-delete-btn" id="record-delete-btn" data-collection="${collection}" data-rkey="${rkey}">Delete</button>
|
||||||
|
` : ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="record-detail">
|
||||||
|
<header class="record-header">
|
||||||
|
<h3>${collection}</h3>
|
||||||
|
<p class="record-uri">URI: ${record.uri}</p>
|
||||||
|
<p class="record-cid">CID: ${record.cid}</p>
|
||||||
|
${deleteBtn}
|
||||||
|
</header>
|
||||||
|
<div class="json-view">
|
||||||
|
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get preview text from record value
|
||||||
|
function getRecordPreview(value: Record<string, unknown>): string {
|
||||||
|
if (typeof value.text === 'string') return value.text.slice(0, 60)
|
||||||
|
if (typeof value.title === 'string') return value.title
|
||||||
|
if (typeof value.name === 'string') return value.name
|
||||||
|
if (typeof value.displayName === 'string') return value.displayName
|
||||||
|
if (typeof value.handle === 'string') return value.handle
|
||||||
|
if (typeof value.subject === 'string') return value.subject
|
||||||
|
if (typeof value.description === 'string') return value.description.slice(0, 60)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
111
src/web/components/discussion.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { searchPostsForUrl, getCurrentNetwork, type SearchPost } from '../lib/api'
|
||||||
|
|
||||||
|
const DISCUSSION_POST_LIMIT = 10
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('ja-JP', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPostUrl(uri: string, appUrl: string): string {
|
||||||
|
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
|
||||||
|
const parts = uri.replace('at://', '').split('/')
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
return `${appUrl}/profile/${parts[0]}/post/${parts[2]}`
|
||||||
|
}
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDiscussion(postUrl: string, appUrl: string = 'https://bsky.app'): string {
|
||||||
|
// Build search URL with host/@username only
|
||||||
|
let searchQuery = postUrl
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(postUrl)
|
||||||
|
const pathParts = urlObj.pathname.split('/').filter(Boolean)
|
||||||
|
// pathParts[0] = @username.domain (e.g., @syui.syui.ai)
|
||||||
|
// Extract just @username
|
||||||
|
if (pathParts[0]?.startsWith('@')) {
|
||||||
|
const handlePart = pathParts[0].slice(1) // remove @
|
||||||
|
const username = handlePart.split('.')[0] // get first part before .
|
||||||
|
searchQuery = `${urlObj.host}/@${username}`
|
||||||
|
} else {
|
||||||
|
searchQuery = urlObj.host
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep original
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="discussion-section">
|
||||||
|
<a href="${searchUrl}" target="_blank" rel="noopener" class="discussion-link">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2.546 20.2A1.5 1.5 0 0 0 4 22h.5l2.83-.892A9.96 9.96 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2z"/>
|
||||||
|
</svg>
|
||||||
|
Discuss on Bluesky
|
||||||
|
</a>
|
||||||
|
<div id="discussion-posts" class="discussion-posts" data-app-url="${escapeHtml(appUrl)}"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDiscussionPosts(container: HTMLElement, postUrl: string, appUrl: string = 'https://bsky.app'): Promise<void> {
|
||||||
|
const postsContainer = container.querySelector('#discussion-posts') as HTMLElement
|
||||||
|
if (!postsContainer) return
|
||||||
|
|
||||||
|
// Get appUrl from network config (overrides default)
|
||||||
|
const network = await getCurrentNetwork()
|
||||||
|
const dataAppUrl = network.web || postsContainer.dataset.appUrl || appUrl
|
||||||
|
|
||||||
|
postsContainer.innerHTML = '<div class="loading-small">Loading comments...</div>'
|
||||||
|
|
||||||
|
const posts = await searchPostsForUrl(postUrl)
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
postsContainer.innerHTML = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map((post: SearchPost) => {
|
||||||
|
const author = post.author
|
||||||
|
const avatar = author.avatar || ''
|
||||||
|
const displayName = author.displayName || author.handle
|
||||||
|
const handle = author.handle
|
||||||
|
const record = post.record as { text?: string; createdAt?: string }
|
||||||
|
const text = record?.text || ''
|
||||||
|
const createdAt = record?.createdAt || ''
|
||||||
|
const postLink = getPostUrl(post.uri, dataAppUrl)
|
||||||
|
|
||||||
|
// Truncate text
|
||||||
|
const truncatedText = text.length > 200 ? text.slice(0, 200) + '...' : text
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="${postLink}" target="_blank" rel="noopener" class="discussion-post">
|
||||||
|
<div class="discussion-author">
|
||||||
|
${avatar ? `<img src="${escapeHtml(avatar)}" class="discussion-avatar" alt="">` : '<div class="discussion-avatar-placeholder"></div>'}
|
||||||
|
<div class="discussion-author-info">
|
||||||
|
<span class="discussion-name">${escapeHtml(displayName)}</span>
|
||||||
|
<span class="discussion-handle">@${escapeHtml(handle)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="discussion-date">${formatDate(createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="discussion-text">${escapeHtml(truncatedText)}</div>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
postsContainer.innerHTML = postsHtml
|
||||||
|
}
|
||||||
17
src/web/components/footer.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function renderFooter(handle: string): string {
|
||||||
|
// Extract username from handle: {username}.{name}.{domain} -> username
|
||||||
|
const username = handle.split('.')[0] || handle
|
||||||
|
|
||||||
|
return `
|
||||||
|
<footer id="footer" class="footer">
|
||||||
|
<div class="license">
|
||||||
|
<a href="https://git.syui.ai/ai/log" target="_blank" rel="noopener">
|
||||||
|
<img src="/ai.svg" alt="ai" class="license-icon">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-content">
|
||||||
|
<span class="footer-copy">© ${username}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
`
|
||||||
|
}
|
||||||
45
src/web/components/header.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
|
||||||
|
|
||||||
|
export function renderHeader(currentHandle: string): string {
|
||||||
|
const loggedIn = isLoggedIn()
|
||||||
|
const handle = getLoggedInHandle()
|
||||||
|
|
||||||
|
const loginBtn = loggedIn
|
||||||
|
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout">${handle || 'logout'}</button>`
|
||||||
|
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login"><img src="/icon/user.svg" alt="Login" class="login-icon"></button>`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<header id="header">
|
||||||
|
<form class="header-form" id="header-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="header-input"
|
||||||
|
id="header-input"
|
||||||
|
placeholder="handle (e.g., syui.ai)"
|
||||||
|
value="${currentHandle}"
|
||||||
|
>
|
||||||
|
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
|
||||||
|
${loginBtn}
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountHeader(
|
||||||
|
container: HTMLElement,
|
||||||
|
currentHandle: string,
|
||||||
|
onBrowse: (handle: string) => void
|
||||||
|
): void {
|
||||||
|
container.innerHTML = renderHeader(currentHandle)
|
||||||
|
|
||||||
|
const form = document.getElementById('header-form') as HTMLFormElement
|
||||||
|
const input = document.getElementById('header-input') as HTMLInputElement
|
||||||
|
|
||||||
|
form?.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const handle = input.value.trim()
|
||||||
|
if (handle) {
|
||||||
|
onBrowse(handle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
22
src/web/components/loading.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Loading indicator component
|
||||||
|
|
||||||
|
export function showLoading(container: HTMLElement): void {
|
||||||
|
const existing = container.querySelector('.loading-overlay')
|
||||||
|
if (existing) return
|
||||||
|
|
||||||
|
const overlay = document.createElement('div')
|
||||||
|
overlay.className = 'loading-overlay'
|
||||||
|
overlay.innerHTML = '<div class="loading-spinner"></div>'
|
||||||
|
container.appendChild(overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideLoading(container: HTMLElement): void {
|
||||||
|
const overlay = container.querySelector('.loading-overlay')
|
||||||
|
if (overlay) {
|
||||||
|
overlay.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLoadingSmall(): string {
|
||||||
|
return '<div class="loading-small">Loading...</div>'
|
||||||
|
}
|
||||||
155
src/web/components/mode-tabs.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { getNetworks } from '../lib/api'
|
||||||
|
import { isLoggedIn } from '../lib/auth'
|
||||||
|
|
||||||
|
let currentNetwork = 'bsky.social'
|
||||||
|
let currentLang = localStorage.getItem('preferred-lang') || 'en'
|
||||||
|
|
||||||
|
export function getCurrentNetwork(): string {
|
||||||
|
return currentNetwork
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentNetwork(network: string): void {
|
||||||
|
currentNetwork = network
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentLang(): string {
|
||||||
|
return currentLang
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentLang(lang: string): void {
|
||||||
|
currentLang = lang
|
||||||
|
localStorage.setItem('preferred-lang', lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string {
|
||||||
|
let tabs = `
|
||||||
|
<a href="/" class="tab">/</a>
|
||||||
|
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
|
||||||
|
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</a>
|
||||||
|
`
|
||||||
|
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs += `
|
||||||
|
<div class="pds-selector" id="pds-selector">
|
||||||
|
<button type="button" class="tab" id="pds-tab">pds</button>
|
||||||
|
<div class="pds-dropdown" id="pds-dropdown"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
return `<div class="mode-tabs">${tabs}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render language selector (above content)
|
||||||
|
export function renderLangSelector(langs: string[]): string {
|
||||||
|
if (langs.length < 2) return ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="lang-selector" id="lang-selector">
|
||||||
|
<button type="button" class="lang-btn" id="lang-tab">
|
||||||
|
<img src="/icon/language.svg" alt="Lang" class="lang-icon">
|
||||||
|
</button>
|
||||||
|
<div class="lang-dropdown" id="lang-dropdown"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupModeTabs(onNetworkChange: (network: string) => void, availableLangs?: string[], onLangChange?: (lang: string) => void): Promise<void> {
|
||||||
|
const pdsTab = document.getElementById('pds-tab')
|
||||||
|
const pdsDropdown = document.getElementById('pds-dropdown')
|
||||||
|
|
||||||
|
if (pdsTab && pdsDropdown) {
|
||||||
|
// Load networks
|
||||||
|
const networks = await getNetworks()
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
const optionsHtml = Object.keys(networks).map(key => {
|
||||||
|
const isSelected = key === currentNetwork
|
||||||
|
return `
|
||||||
|
<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${key}">
|
||||||
|
<span class="pds-name">${key}</span>
|
||||||
|
<span class="pds-check">✓</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
pdsDropdown.innerHTML = optionsHtml
|
||||||
|
|
||||||
|
// Toggle dropdown
|
||||||
|
pdsTab.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
pdsDropdown.classList.toggle('show')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle option selection
|
||||||
|
pdsDropdown.querySelectorAll('.pds-option').forEach(opt => {
|
||||||
|
opt.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const network = (opt as HTMLElement).dataset.network || ''
|
||||||
|
|
||||||
|
currentNetwork = network
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
pdsDropdown.querySelectorAll('.pds-option').forEach(o => {
|
||||||
|
o.classList.remove('selected')
|
||||||
|
})
|
||||||
|
opt.classList.add('selected')
|
||||||
|
pdsDropdown.classList.remove('show')
|
||||||
|
|
||||||
|
onNetworkChange(network)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup language selector
|
||||||
|
const langTab = document.getElementById('lang-tab')
|
||||||
|
const langDropdown = document.getElementById('lang-dropdown')
|
||||||
|
|
||||||
|
if (langTab && langDropdown && availableLangs && availableLangs.length > 0) {
|
||||||
|
// Build language options
|
||||||
|
const langOptionsHtml = availableLangs.map(lang => {
|
||||||
|
const isSelected = lang === currentLang
|
||||||
|
return `
|
||||||
|
<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang}">
|
||||||
|
<span class="lang-name">${lang.toUpperCase()}</span>
|
||||||
|
<span class="lang-check">✓</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
langDropdown.innerHTML = langOptionsHtml
|
||||||
|
|
||||||
|
// Toggle dropdown
|
||||||
|
langTab.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
langDropdown.classList.toggle('show')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle option selection
|
||||||
|
langDropdown.querySelectorAll('.lang-option').forEach(opt => {
|
||||||
|
opt.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const lang = (opt as HTMLElement).dataset.lang || ''
|
||||||
|
|
||||||
|
setCurrentLang(lang)
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
langDropdown.querySelectorAll('.lang-option').forEach(o => {
|
||||||
|
o.classList.remove('selected')
|
||||||
|
})
|
||||||
|
opt.classList.add('selected')
|
||||||
|
langDropdown.classList.remove('show')
|
||||||
|
|
||||||
|
if (onLangChange) onLangChange(lang)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdowns on outside click
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
pdsDropdown?.classList.remove('show')
|
||||||
|
langDropdown?.classList.remove('show')
|
||||||
|
})
|
||||||
|
}
|
||||||
91
src/web/components/pds-selector.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { getNetworks } from '../lib/api'
|
||||||
|
|
||||||
|
let currentPds: string | null = null
|
||||||
|
|
||||||
|
export function getCurrentPds(): string | null {
|
||||||
|
return currentPds
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentPds(pds: string): void {
|
||||||
|
currentPds = pds
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPdsSelector(): string {
|
||||||
|
return `
|
||||||
|
<div class="pds-selector">
|
||||||
|
<button class="pds-trigger" id="pds-trigger">
|
||||||
|
<span>pds</span>
|
||||||
|
</button>
|
||||||
|
<div class="pds-dropdown" id="pds-dropdown" style="display: none;">
|
||||||
|
<div class="pds-dropdown-content" id="pds-dropdown-content">
|
||||||
|
<!-- Options loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupPdsSelector(onSelect: (pds: string, domain: string) => void): Promise<void> {
|
||||||
|
const trigger = document.getElementById('pds-trigger')
|
||||||
|
const dropdown = document.getElementById('pds-dropdown')
|
||||||
|
const content = document.getElementById('pds-dropdown-content')
|
||||||
|
|
||||||
|
if (!trigger || !dropdown || !content) return
|
||||||
|
|
||||||
|
// Load networks and build options
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const firstDomain = Object.keys(networks)[0]
|
||||||
|
|
||||||
|
// Set default
|
||||||
|
if (!currentPds && firstDomain) {
|
||||||
|
currentPds = networks[firstDomain].bsky
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsHtml = Object.entries(networks).map(([domain, network]) => {
|
||||||
|
const isSelected = currentPds === network.bsky
|
||||||
|
return `
|
||||||
|
<button class="pds-option ${isSelected ? 'selected' : ''}" data-pds="${network.bsky}" data-domain="${domain}">
|
||||||
|
<span class="pds-option-name">${domain}</span>
|
||||||
|
<span class="pds-option-check">${isSelected ? '●' : '○'}</span>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
content.innerHTML = optionsHtml
|
||||||
|
|
||||||
|
// Toggle dropdown
|
||||||
|
trigger.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const isVisible = dropdown.style.display !== 'none'
|
||||||
|
dropdown.style.display = isVisible ? 'none' : 'block'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
dropdown.style.display = 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle option selection
|
||||||
|
content.querySelectorAll('.pds-option').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const pds = btn.getAttribute('data-pds') || ''
|
||||||
|
const domain = btn.getAttribute('data-domain') || ''
|
||||||
|
|
||||||
|
currentPds = pds
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
content.querySelectorAll('.pds-option').forEach(b => {
|
||||||
|
b.classList.remove('selected')
|
||||||
|
const check = b.querySelector('.pds-option-check')
|
||||||
|
if (check) check.textContent = '○'
|
||||||
|
})
|
||||||
|
btn.classList.add('selected')
|
||||||
|
const check = btn.querySelector('.pds-option-check')
|
||||||
|
if (check) check.textContent = '●'
|
||||||
|
|
||||||
|
dropdown.style.display = 'none'
|
||||||
|
onSelect(pds, domain)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
69
src/web/components/postform.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { createPost } from '../lib/auth'
|
||||||
|
|
||||||
|
export function renderPostForm(collection: string): string {
|
||||||
|
return `
|
||||||
|
<div class="post-form-container">
|
||||||
|
<form class="post-form" id="post-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="post-form-title"
|
||||||
|
id="post-title"
|
||||||
|
placeholder="Title"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
class="post-form-body"
|
||||||
|
id="post-body"
|
||||||
|
placeholder="Content (markdown)"
|
||||||
|
rows="6"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<div class="post-form-footer">
|
||||||
|
<span class="post-form-collection">${collection}</span>
|
||||||
|
<button type="submit" class="post-form-btn" id="post-submit">Post</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="post-status" class="post-status"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupPostForm(collection: string, onSuccess: () => void): void {
|
||||||
|
const form = document.getElementById('post-form') as HTMLFormElement
|
||||||
|
const titleInput = document.getElementById('post-title') as HTMLInputElement
|
||||||
|
const bodyInput = document.getElementById('post-body') as HTMLTextAreaElement
|
||||||
|
const submitBtn = document.getElementById('post-submit') as HTMLButtonElement
|
||||||
|
const statusEl = document.getElementById('post-status') as HTMLDivElement
|
||||||
|
|
||||||
|
if (!form) return
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const title = titleInput.value.trim()
|
||||||
|
const body = bodyInput.value.trim()
|
||||||
|
|
||||||
|
if (!title || !body) return
|
||||||
|
|
||||||
|
submitBtn.disabled = true
|
||||||
|
submitBtn.textContent = 'Posting...'
|
||||||
|
statusEl.innerHTML = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createPost(collection, title, body)
|
||||||
|
if (result) {
|
||||||
|
statusEl.innerHTML = `<span class="post-success">Posted!</span>`
|
||||||
|
titleInput.value = ''
|
||||||
|
bodyInput.value = ''
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.innerHTML = `<span class="post-error">Error: ${err}</span>`
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false
|
||||||
|
submitBtn.textContent = 'Post'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
132
src/web/components/posts.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Post } from '../types'
|
||||||
|
import { renderMarkdown } from '../lib/markdown'
|
||||||
|
import { renderDiscussion, loadDiscussionPosts } from './discussion'
|
||||||
|
import { getCurrentLang } from './mode-tabs'
|
||||||
|
|
||||||
|
// Render post list
|
||||||
|
export function renderPostList(posts: Post[], handle: string): string {
|
||||||
|
if (posts.length === 0) {
|
||||||
|
return '<p class="no-posts">No posts yet.</p>'
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLang = getCurrentLang()
|
||||||
|
|
||||||
|
const items = posts.map(post => {
|
||||||
|
const rkey = post.uri.split('/').pop() || ''
|
||||||
|
const date = new Date(post.value.createdAt).toLocaleDateString('en-US')
|
||||||
|
const originalLang = post.value.lang || 'ja'
|
||||||
|
const translations = post.value.translations
|
||||||
|
|
||||||
|
// Use translation if available
|
||||||
|
let displayTitle = post.value.title
|
||||||
|
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
||||||
|
displayTitle = translations[currentLang].title || post.value.title
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="post-item">
|
||||||
|
<a href="/@${handle}/${rkey}" class="post-link">
|
||||||
|
<h2 class="post-title">${escapeHtml(displayTitle)}</h2>
|
||||||
|
<time class="post-date">${date}</time>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<div class="post-list">${items}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render single post detail
|
||||||
|
export function renderPostDetail(
|
||||||
|
post: Post,
|
||||||
|
handle: string,
|
||||||
|
collection: string,
|
||||||
|
isOwner: boolean = false,
|
||||||
|
siteUrl?: string,
|
||||||
|
appUrl: string = 'https://bsky.app'
|
||||||
|
): string {
|
||||||
|
const rkey = post.uri.split('/').pop() || ''
|
||||||
|
const date = new Date(post.value.createdAt).toLocaleDateString('en-US')
|
||||||
|
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}`
|
||||||
|
|
||||||
|
// Build post URL for discussion search
|
||||||
|
const postUrl = siteUrl ? `${siteUrl}/@${handle}/${rkey}` : `${window.location.origin}/@${handle}/${rkey}`
|
||||||
|
|
||||||
|
const editBtn = isOwner ? `<button type="button" class="post-edit-btn" id="post-edit-btn">Edit</button>` : ''
|
||||||
|
|
||||||
|
// Get current language and show appropriate content
|
||||||
|
const currentLang = getCurrentLang()
|
||||||
|
const translations = post.value.translations
|
||||||
|
const originalLang = post.value.lang || 'ja'
|
||||||
|
|
||||||
|
let displayTitle = post.value.title
|
||||||
|
let displayContent = post.value.content
|
||||||
|
|
||||||
|
// Use translation if available and not original language
|
||||||
|
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
||||||
|
const trans = translations[currentLang]
|
||||||
|
displayTitle = trans.title || post.value.title
|
||||||
|
displayContent = trans.content
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderMarkdown(displayContent)
|
||||||
|
|
||||||
|
const editForm = isOwner ? `
|
||||||
|
<div class="post-edit-form" id="post-edit-form" style="display: none;">
|
||||||
|
<input type="text" class="post-edit-title" id="post-edit-title" value="${escapeHtml(post.value.title)}" placeholder="Title">
|
||||||
|
<textarea class="post-edit-content" id="post-edit-content" rows="15">${escapeHtml(post.value.content)}</textarea>
|
||||||
|
<div class="post-edit-actions">
|
||||||
|
<button type="button" class="post-edit-cancel" id="post-edit-cancel">Cancel</button>
|
||||||
|
<button type="button" class="post-edit-save" id="post-edit-save" data-collection="${collection}" data-rkey="${rkey}">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="post-detail" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}">
|
||||||
|
<header class="post-header">
|
||||||
|
<div class="post-meta">
|
||||||
|
<time class="post-date">${date}</time>
|
||||||
|
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||||
|
${editBtn}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
${editForm}
|
||||||
|
<div id="post-display">
|
||||||
|
<h1 class="post-title">${escapeHtml(displayTitle)}</h1>
|
||||||
|
<div class="post-content">${content}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
${renderDiscussion(postUrl, appUrl)}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup post detail interactions (discussion loading)
|
||||||
|
export function setupPostDetail(container: HTMLElement): void {
|
||||||
|
const article = container.querySelector('.post-detail') as HTMLElement
|
||||||
|
if (!article) return
|
||||||
|
|
||||||
|
// Load discussion posts
|
||||||
|
const postUrl = article.dataset.postUrl
|
||||||
|
const appUrl = article.dataset.appUrl || 'https://bsky.app'
|
||||||
|
if (postUrl) {
|
||||||
|
loadDiscussionPosts(container, postUrl, appUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountPostList(container: HTMLElement, html: string): void {
|
||||||
|
container.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountPostDetail(container: HTMLElement, html: string): void {
|
||||||
|
container.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
48
src/web/components/profile.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Profile } from '../types'
|
||||||
|
import { getAvatarUrl } from '../lib/api'
|
||||||
|
|
||||||
|
export async function renderProfile(
|
||||||
|
did: string,
|
||||||
|
profile: Profile,
|
||||||
|
handle: string,
|
||||||
|
webUrl?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const avatarUrl = await getAvatarUrl(did, profile)
|
||||||
|
const displayName = profile.value.displayName || handle || 'Unknown'
|
||||||
|
const description = profile.value.description || ''
|
||||||
|
|
||||||
|
// Build profile link (e.g., https://bsky.app/profile/did:plc:xxx)
|
||||||
|
const profileLink = webUrl ? `${webUrl}/profile/${did}` : null
|
||||||
|
|
||||||
|
const handleHtml = profileLink
|
||||||
|
? `<a href="${profileLink}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(handle)}</a>`
|
||||||
|
: `<span>@${escapeHtml(handle)}</span>`
|
||||||
|
|
||||||
|
const avatarHtml = avatarUrl
|
||||||
|
? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
|
||||||
|
: `<div class="profile-avatar-placeholder"></div>`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="profile">
|
||||||
|
${avatarHtml}
|
||||||
|
<div class="profile-info">
|
||||||
|
<h1 class="profile-name">${escapeHtml(displayName)}</h1>
|
||||||
|
<p class="profile-handle">${handleHtml}</p>
|
||||||
|
${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountProfile(container: HTMLElement, html: string): void {
|
||||||
|
container.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
13
src/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ailog</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
260
src/web/lexicons/index.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// Auto-generated from ATProto lexicons
|
||||||
|
// Run `ailog gen` to regenerate
|
||||||
|
// Do not edit manually
|
||||||
|
|
||||||
|
export interface Endpoint {
|
||||||
|
nsid: string
|
||||||
|
method: 'GET' | 'POST'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build XRPC URL for an endpoint */
|
||||||
|
export function xrpcUrl(pds: string, endpoint: Endpoint): string {
|
||||||
|
return `https://${pds}/xrpc/${endpoint.nsid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appBskyActor = {
|
||||||
|
getPreferences: { nsid: 'app.bsky.actor.getPreferences', method: 'GET' } as Endpoint,
|
||||||
|
getProfile: { nsid: 'app.bsky.actor.getProfile', method: 'GET' } as Endpoint,
|
||||||
|
getProfiles: { nsid: 'app.bsky.actor.getProfiles', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestions: { nsid: 'app.bsky.actor.getSuggestions', method: 'GET' } as Endpoint,
|
||||||
|
putPreferences: { nsid: 'app.bsky.actor.putPreferences', method: 'POST' } as Endpoint,
|
||||||
|
searchActors: { nsid: 'app.bsky.actor.searchActors', method: 'GET' } as Endpoint,
|
||||||
|
searchActorsTypeahead: { nsid: 'app.bsky.actor.searchActorsTypeahead', method: 'GET' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyAgeassurance = {
|
||||||
|
begin: { nsid: 'app.bsky.ageassurance.begin', method: 'POST' } as Endpoint,
|
||||||
|
getConfig: { nsid: 'app.bsky.ageassurance.getConfig', method: 'GET' } as Endpoint,
|
||||||
|
getState: { nsid: 'app.bsky.ageassurance.getState', method: 'GET' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyBookmark = {
|
||||||
|
createBookmark: { nsid: 'app.bsky.bookmark.createBookmark', method: 'POST' } as Endpoint,
|
||||||
|
deleteBookmark: { nsid: 'app.bsky.bookmark.deleteBookmark', method: 'POST' } as Endpoint,
|
||||||
|
getBookmarks: { nsid: 'app.bsky.bookmark.getBookmarks', method: 'GET' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyContact = {
|
||||||
|
dismissMatch: { nsid: 'app.bsky.contact.dismissMatch', method: 'POST' } as Endpoint,
|
||||||
|
getMatches: { nsid: 'app.bsky.contact.getMatches', method: 'GET' } as Endpoint,
|
||||||
|
getSyncStatus: { nsid: 'app.bsky.contact.getSyncStatus', method: 'GET' } as Endpoint,
|
||||||
|
importContacts: { nsid: 'app.bsky.contact.importContacts', method: 'POST' } as Endpoint,
|
||||||
|
removeData: { nsid: 'app.bsky.contact.removeData', method: 'POST' } as Endpoint,
|
||||||
|
sendNotification: { nsid: 'app.bsky.contact.sendNotification', method: 'POST' } as Endpoint,
|
||||||
|
startPhoneVerification: { nsid: 'app.bsky.contact.startPhoneVerification', method: 'POST' } as Endpoint,
|
||||||
|
verifyPhone: { nsid: 'app.bsky.contact.verifyPhone', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyDraft = {
|
||||||
|
createDraft: { nsid: 'app.bsky.draft.createDraft', method: 'POST' } as Endpoint,
|
||||||
|
deleteDraft: { nsid: 'app.bsky.draft.deleteDraft', method: 'POST' } as Endpoint,
|
||||||
|
getDrafts: { nsid: 'app.bsky.draft.getDrafts', method: 'GET' } as Endpoint,
|
||||||
|
updateDraft: { nsid: 'app.bsky.draft.updateDraft', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyFeed = {
|
||||||
|
describeFeedGenerator: { nsid: 'app.bsky.feed.describeFeedGenerator', method: 'GET' } as Endpoint,
|
||||||
|
getActorFeeds: { nsid: 'app.bsky.feed.getActorFeeds', method: 'GET' } as Endpoint,
|
||||||
|
getActorLikes: { nsid: 'app.bsky.feed.getActorLikes', method: 'GET' } as Endpoint,
|
||||||
|
getAuthorFeed: { nsid: 'app.bsky.feed.getAuthorFeed', method: 'GET' } as Endpoint,
|
||||||
|
getFeed: { nsid: 'app.bsky.feed.getFeed', method: 'GET' } as Endpoint,
|
||||||
|
getFeedGenerator: { nsid: 'app.bsky.feed.getFeedGenerator', method: 'GET' } as Endpoint,
|
||||||
|
getFeedGenerators: { nsid: 'app.bsky.feed.getFeedGenerators', method: 'GET' } as Endpoint,
|
||||||
|
getFeedSkeleton: { nsid: 'app.bsky.feed.getFeedSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
getLikes: { nsid: 'app.bsky.feed.getLikes', method: 'GET' } as Endpoint,
|
||||||
|
getListFeed: { nsid: 'app.bsky.feed.getListFeed', method: 'GET' } as Endpoint,
|
||||||
|
getPostThread: { nsid: 'app.bsky.feed.getPostThread', method: 'GET' } as Endpoint,
|
||||||
|
getPosts: { nsid: 'app.bsky.feed.getPosts', method: 'GET' } as Endpoint,
|
||||||
|
getQuotes: { nsid: 'app.bsky.feed.getQuotes', method: 'GET' } as Endpoint,
|
||||||
|
getRepostedBy: { nsid: 'app.bsky.feed.getRepostedBy', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedFeeds: { nsid: 'app.bsky.feed.getSuggestedFeeds', method: 'GET' } as Endpoint,
|
||||||
|
getTimeline: { nsid: 'app.bsky.feed.getTimeline', method: 'GET' } as Endpoint,
|
||||||
|
searchPosts: { nsid: 'app.bsky.feed.searchPosts', method: 'GET' } as Endpoint,
|
||||||
|
sendInteractions: { nsid: 'app.bsky.feed.sendInteractions', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyGraph = {
|
||||||
|
getActorStarterPacks: { nsid: 'app.bsky.graph.getActorStarterPacks', method: 'GET' } as Endpoint,
|
||||||
|
getBlocks: { nsid: 'app.bsky.graph.getBlocks', method: 'GET' } as Endpoint,
|
||||||
|
getFollowers: { nsid: 'app.bsky.graph.getFollowers', method: 'GET' } as Endpoint,
|
||||||
|
getFollows: { nsid: 'app.bsky.graph.getFollows', method: 'GET' } as Endpoint,
|
||||||
|
getKnownFollowers: { nsid: 'app.bsky.graph.getKnownFollowers', method: 'GET' } as Endpoint,
|
||||||
|
getList: { nsid: 'app.bsky.graph.getList', method: 'GET' } as Endpoint,
|
||||||
|
getListBlocks: { nsid: 'app.bsky.graph.getListBlocks', method: 'GET' } as Endpoint,
|
||||||
|
getListMutes: { nsid: 'app.bsky.graph.getListMutes', method: 'GET' } as Endpoint,
|
||||||
|
getLists: { nsid: 'app.bsky.graph.getLists', method: 'GET' } as Endpoint,
|
||||||
|
getListsWithMembership: { nsid: 'app.bsky.graph.getListsWithMembership', method: 'GET' } as Endpoint,
|
||||||
|
getMutes: { nsid: 'app.bsky.graph.getMutes', method: 'GET' } as Endpoint,
|
||||||
|
getRelationships: { nsid: 'app.bsky.graph.getRelationships', method: 'GET' } as Endpoint,
|
||||||
|
getStarterPack: { nsid: 'app.bsky.graph.getStarterPack', method: 'GET' } as Endpoint,
|
||||||
|
getStarterPacks: { nsid: 'app.bsky.graph.getStarterPacks', method: 'GET' } as Endpoint,
|
||||||
|
getStarterPacksWithMembership: { nsid: 'app.bsky.graph.getStarterPacksWithMembership', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedFollowsByActor: { nsid: 'app.bsky.graph.getSuggestedFollowsByActor', method: 'GET' } as Endpoint,
|
||||||
|
muteActor: { nsid: 'app.bsky.graph.muteActor', method: 'POST' } as Endpoint,
|
||||||
|
muteActorList: { nsid: 'app.bsky.graph.muteActorList', method: 'POST' } as Endpoint,
|
||||||
|
muteThread: { nsid: 'app.bsky.graph.muteThread', method: 'POST' } as Endpoint,
|
||||||
|
searchStarterPacks: { nsid: 'app.bsky.graph.searchStarterPacks', method: 'GET' } as Endpoint,
|
||||||
|
unmuteActor: { nsid: 'app.bsky.graph.unmuteActor', method: 'POST' } as Endpoint,
|
||||||
|
unmuteActorList: { nsid: 'app.bsky.graph.unmuteActorList', method: 'POST' } as Endpoint,
|
||||||
|
unmuteThread: { nsid: 'app.bsky.graph.unmuteThread', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyLabeler = {
|
||||||
|
getServices: { nsid: 'app.bsky.labeler.getServices', method: 'GET' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyNotification = {
|
||||||
|
getPreferences: { nsid: 'app.bsky.notification.getPreferences', method: 'GET' } as Endpoint,
|
||||||
|
getUnreadCount: { nsid: 'app.bsky.notification.getUnreadCount', method: 'GET' } as Endpoint,
|
||||||
|
listActivitySubscriptions: { nsid: 'app.bsky.notification.listActivitySubscriptions', method: 'GET' } as Endpoint,
|
||||||
|
listNotifications: { nsid: 'app.bsky.notification.listNotifications', method: 'GET' } as Endpoint,
|
||||||
|
putActivitySubscription: { nsid: 'app.bsky.notification.putActivitySubscription', method: 'POST' } as Endpoint,
|
||||||
|
putPreferences: { nsid: 'app.bsky.notification.putPreferences', method: 'POST' } as Endpoint,
|
||||||
|
putPreferencesV2: { nsid: 'app.bsky.notification.putPreferencesV2', method: 'POST' } as Endpoint,
|
||||||
|
registerPush: { nsid: 'app.bsky.notification.registerPush', method: 'POST' } as Endpoint,
|
||||||
|
unregisterPush: { nsid: 'app.bsky.notification.unregisterPush', method: 'POST' } as Endpoint,
|
||||||
|
updateSeen: { nsid: 'app.bsky.notification.updateSeen', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyUnspecced = {
|
||||||
|
getAgeAssuranceState: { nsid: 'app.bsky.unspecced.getAgeAssuranceState', method: 'GET' } as Endpoint,
|
||||||
|
getConfig: { nsid: 'app.bsky.unspecced.getConfig', method: 'GET' } as Endpoint,
|
||||||
|
getOnboardingSuggestedStarterPacks: { nsid: 'app.bsky.unspecced.getOnboardingSuggestedStarterPacks', method: 'GET' } as Endpoint,
|
||||||
|
getOnboardingSuggestedStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
getPopularFeedGenerators: { nsid: 'app.bsky.unspecced.getPopularFeedGenerators', method: 'GET' } as Endpoint,
|
||||||
|
getPostThreadOtherV2: { nsid: 'app.bsky.unspecced.getPostThreadOtherV2', method: 'GET' } as Endpoint,
|
||||||
|
getPostThreadV2: { nsid: 'app.bsky.unspecced.getPostThreadV2', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedFeeds: { nsid: 'app.bsky.unspecced.getSuggestedFeeds', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedFeedsSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedFeedsSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedStarterPacks: { nsid: 'app.bsky.unspecced.getSuggestedStarterPacks', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedStarterPacksSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedUsers: { nsid: 'app.bsky.unspecced.getSuggestedUsers', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestedUsersSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedUsersSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
getSuggestionsSkeleton: { nsid: 'app.bsky.unspecced.getSuggestionsSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
getTaggedSuggestions: { nsid: 'app.bsky.unspecced.getTaggedSuggestions', method: 'GET' } as Endpoint,
|
||||||
|
getTrendingTopics: { nsid: 'app.bsky.unspecced.getTrendingTopics', method: 'GET' } as Endpoint,
|
||||||
|
getTrends: { nsid: 'app.bsky.unspecced.getTrends', method: 'GET' } as Endpoint,
|
||||||
|
getTrendsSkeleton: { nsid: 'app.bsky.unspecced.getTrendsSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
initAgeAssurance: { nsid: 'app.bsky.unspecced.initAgeAssurance', method: 'POST' } as Endpoint,
|
||||||
|
searchActorsSkeleton: { nsid: 'app.bsky.unspecced.searchActorsSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
searchPostsSkeleton: { nsid: 'app.bsky.unspecced.searchPostsSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
searchStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.searchStarterPacksSkeleton', method: 'GET' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const appBskyVideo = {
|
||||||
|
getJobStatus: { nsid: 'app.bsky.video.getJobStatus', method: 'GET' } as Endpoint,
|
||||||
|
getUploadLimits: { nsid: 'app.bsky.video.getUploadLimits', method: 'GET' } as Endpoint,
|
||||||
|
uploadVideo: { nsid: 'app.bsky.video.uploadVideo', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoAdmin = {
|
||||||
|
deleteAccount: { nsid: 'com.atproto.admin.deleteAccount', method: 'POST' } as Endpoint,
|
||||||
|
disableAccountInvites: { nsid: 'com.atproto.admin.disableAccountInvites', method: 'POST' } as Endpoint,
|
||||||
|
disableInviteCodes: { nsid: 'com.atproto.admin.disableInviteCodes', method: 'POST' } as Endpoint,
|
||||||
|
enableAccountInvites: { nsid: 'com.atproto.admin.enableAccountInvites', method: 'POST' } as Endpoint,
|
||||||
|
getAccountInfo: { nsid: 'com.atproto.admin.getAccountInfo', method: 'GET' } as Endpoint,
|
||||||
|
getAccountInfos: { nsid: 'com.atproto.admin.getAccountInfos', method: 'GET' } as Endpoint,
|
||||||
|
getInviteCodes: { nsid: 'com.atproto.admin.getInviteCodes', method: 'GET' } as Endpoint,
|
||||||
|
getSubjectStatus: { nsid: 'com.atproto.admin.getSubjectStatus', method: 'GET' } as Endpoint,
|
||||||
|
searchAccounts: { nsid: 'com.atproto.admin.searchAccounts', method: 'GET' } as Endpoint,
|
||||||
|
sendEmail: { nsid: 'com.atproto.admin.sendEmail', method: 'POST' } as Endpoint,
|
||||||
|
updateAccountEmail: { nsid: 'com.atproto.admin.updateAccountEmail', method: 'POST' } as Endpoint,
|
||||||
|
updateAccountHandle: { nsid: 'com.atproto.admin.updateAccountHandle', method: 'POST' } as Endpoint,
|
||||||
|
updateAccountPassword: { nsid: 'com.atproto.admin.updateAccountPassword', method: 'POST' } as Endpoint,
|
||||||
|
updateAccountSigningKey: { nsid: 'com.atproto.admin.updateAccountSigningKey', method: 'POST' } as Endpoint,
|
||||||
|
updateSubjectStatus: { nsid: 'com.atproto.admin.updateSubjectStatus', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoIdentity = {
|
||||||
|
getRecommendedDidCredentials: { nsid: 'com.atproto.identity.getRecommendedDidCredentials', method: 'GET' } as Endpoint,
|
||||||
|
refreshIdentity: { nsid: 'com.atproto.identity.refreshIdentity', method: 'POST' } as Endpoint,
|
||||||
|
requestPlcOperationSignature: { nsid: 'com.atproto.identity.requestPlcOperationSignature', method: 'POST' } as Endpoint,
|
||||||
|
resolveDid: { nsid: 'com.atproto.identity.resolveDid', method: 'GET' } as Endpoint,
|
||||||
|
resolveHandle: { nsid: 'com.atproto.identity.resolveHandle', method: 'GET' } as Endpoint,
|
||||||
|
resolveIdentity: { nsid: 'com.atproto.identity.resolveIdentity', method: 'GET' } as Endpoint,
|
||||||
|
signPlcOperation: { nsid: 'com.atproto.identity.signPlcOperation', method: 'POST' } as Endpoint,
|
||||||
|
submitPlcOperation: { nsid: 'com.atproto.identity.submitPlcOperation', method: 'POST' } as Endpoint,
|
||||||
|
updateHandle: { nsid: 'com.atproto.identity.updateHandle', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoLabel = {
|
||||||
|
queryLabels: { nsid: 'com.atproto.label.queryLabels', method: 'GET' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoLexicon = {
|
||||||
|
resolveLexicon: { nsid: 'com.atproto.lexicon.resolveLexicon', method: 'GET' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoModeration = {
|
||||||
|
createReport: { nsid: 'com.atproto.moderation.createReport', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoRepo = {
|
||||||
|
applyWrites: { nsid: 'com.atproto.repo.applyWrites', method: 'POST' } as Endpoint,
|
||||||
|
createRecord: { nsid: 'com.atproto.repo.createRecord', method: 'POST' } as Endpoint,
|
||||||
|
deleteRecord: { nsid: 'com.atproto.repo.deleteRecord', method: 'POST' } as Endpoint,
|
||||||
|
describeRepo: { nsid: 'com.atproto.repo.describeRepo', method: 'GET' } as Endpoint,
|
||||||
|
getRecord: { nsid: 'com.atproto.repo.getRecord', method: 'GET' } as Endpoint,
|
||||||
|
importRepo: { nsid: 'com.atproto.repo.importRepo', method: 'POST' } as Endpoint,
|
||||||
|
listMissingBlobs: { nsid: 'com.atproto.repo.listMissingBlobs', method: 'GET' } as Endpoint,
|
||||||
|
listRecords: { nsid: 'com.atproto.repo.listRecords', method: 'GET' } as Endpoint,
|
||||||
|
putRecord: { nsid: 'com.atproto.repo.putRecord', method: 'POST' } as Endpoint,
|
||||||
|
uploadBlob: { nsid: 'com.atproto.repo.uploadBlob', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoServer = {
|
||||||
|
activateAccount: { nsid: 'com.atproto.server.activateAccount', method: 'POST' } as Endpoint,
|
||||||
|
checkAccountStatus: { nsid: 'com.atproto.server.checkAccountStatus', method: 'GET' } as Endpoint,
|
||||||
|
confirmEmail: { nsid: 'com.atproto.server.confirmEmail', method: 'POST' } as Endpoint,
|
||||||
|
createAccount: { nsid: 'com.atproto.server.createAccount', method: 'POST' } as Endpoint,
|
||||||
|
createAppPassword: { nsid: 'com.atproto.server.createAppPassword', method: 'POST' } as Endpoint,
|
||||||
|
createInviteCode: { nsid: 'com.atproto.server.createInviteCode', method: 'POST' } as Endpoint,
|
||||||
|
createInviteCodes: { nsid: 'com.atproto.server.createInviteCodes', method: 'POST' } as Endpoint,
|
||||||
|
createSession: { nsid: 'com.atproto.server.createSession', method: 'POST' } as Endpoint,
|
||||||
|
deactivateAccount: { nsid: 'com.atproto.server.deactivateAccount', method: 'POST' } as Endpoint,
|
||||||
|
deleteAccount: { nsid: 'com.atproto.server.deleteAccount', method: 'POST' } as Endpoint,
|
||||||
|
deleteSession: { nsid: 'com.atproto.server.deleteSession', method: 'POST' } as Endpoint,
|
||||||
|
describeServer: { nsid: 'com.atproto.server.describeServer', method: 'GET' } as Endpoint,
|
||||||
|
getAccountInviteCodes: { nsid: 'com.atproto.server.getAccountInviteCodes', method: 'GET' } as Endpoint,
|
||||||
|
getServiceAuth: { nsid: 'com.atproto.server.getServiceAuth', method: 'GET' } as Endpoint,
|
||||||
|
getSession: { nsid: 'com.atproto.server.getSession', method: 'GET' } as Endpoint,
|
||||||
|
listAppPasswords: { nsid: 'com.atproto.server.listAppPasswords', method: 'GET' } as Endpoint,
|
||||||
|
refreshSession: { nsid: 'com.atproto.server.refreshSession', method: 'POST' } as Endpoint,
|
||||||
|
requestAccountDelete: { nsid: 'com.atproto.server.requestAccountDelete', method: 'POST' } as Endpoint,
|
||||||
|
requestEmailConfirmation: { nsid: 'com.atproto.server.requestEmailConfirmation', method: 'POST' } as Endpoint,
|
||||||
|
requestEmailUpdate: { nsid: 'com.atproto.server.requestEmailUpdate', method: 'POST' } as Endpoint,
|
||||||
|
requestPasswordReset: { nsid: 'com.atproto.server.requestPasswordReset', method: 'POST' } as Endpoint,
|
||||||
|
reserveSigningKey: { nsid: 'com.atproto.server.reserveSigningKey', method: 'POST' } as Endpoint,
|
||||||
|
resetPassword: { nsid: 'com.atproto.server.resetPassword', method: 'POST' } as Endpoint,
|
||||||
|
revokeAppPassword: { nsid: 'com.atproto.server.revokeAppPassword', method: 'POST' } as Endpoint,
|
||||||
|
updateEmail: { nsid: 'com.atproto.server.updateEmail', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoSync = {
|
||||||
|
getBlob: { nsid: 'com.atproto.sync.getBlob', method: 'GET' } as Endpoint,
|
||||||
|
getBlocks: { nsid: 'com.atproto.sync.getBlocks', method: 'GET' } as Endpoint,
|
||||||
|
getCheckout: { nsid: 'com.atproto.sync.getCheckout', method: 'GET' } as Endpoint,
|
||||||
|
getHead: { nsid: 'com.atproto.sync.getHead', method: 'GET' } as Endpoint,
|
||||||
|
getHostStatus: { nsid: 'com.atproto.sync.getHostStatus', method: 'GET' } as Endpoint,
|
||||||
|
getLatestCommit: { nsid: 'com.atproto.sync.getLatestCommit', method: 'GET' } as Endpoint,
|
||||||
|
getRecord: { nsid: 'com.atproto.sync.getRecord', method: 'GET' } as Endpoint,
|
||||||
|
getRepo: { nsid: 'com.atproto.sync.getRepo', method: 'GET' } as Endpoint,
|
||||||
|
getRepoStatus: { nsid: 'com.atproto.sync.getRepoStatus', method: 'GET' } as Endpoint,
|
||||||
|
listBlobs: { nsid: 'com.atproto.sync.listBlobs', method: 'GET' } as Endpoint,
|
||||||
|
listHosts: { nsid: 'com.atproto.sync.listHosts', method: 'GET' } as Endpoint,
|
||||||
|
listRepos: { nsid: 'com.atproto.sync.listRepos', method: 'GET' } as Endpoint,
|
||||||
|
listReposByCollection: { nsid: 'com.atproto.sync.listReposByCollection', method: 'GET' } as Endpoint,
|
||||||
|
notifyOfUpdate: { nsid: 'com.atproto.sync.notifyOfUpdate', method: 'POST' } as Endpoint,
|
||||||
|
requestCrawl: { nsid: 'com.atproto.sync.requestCrawl', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const comAtprotoTemp = {
|
||||||
|
addReservedHandle: { nsid: 'com.atproto.temp.addReservedHandle', method: 'POST' } as Endpoint,
|
||||||
|
checkHandleAvailability: { nsid: 'com.atproto.temp.checkHandleAvailability', method: 'GET' } as Endpoint,
|
||||||
|
checkSignupQueue: { nsid: 'com.atproto.temp.checkSignupQueue', method: 'GET' } as Endpoint,
|
||||||
|
dereferenceScope: { nsid: 'com.atproto.temp.dereferenceScope', method: 'GET' } as Endpoint,
|
||||||
|
fetchLabels: { nsid: 'com.atproto.temp.fetchLabels', method: 'GET' } as Endpoint,
|
||||||
|
requestPhoneVerification: { nsid: 'com.atproto.temp.requestPhoneVerification', method: 'POST' } as Endpoint,
|
||||||
|
revokeAccountCredentials: { nsid: 'com.atproto.temp.revokeAccountCredentials', method: 'POST' } as Endpoint,
|
||||||
|
} as const
|
||||||
|
|
||||||
340
src/web/lib/api.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
|
||||||
|
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse } from '../types'
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
let configCache: AppConfig | null = null
|
||||||
|
let networksCache: Networks | null = null
|
||||||
|
|
||||||
|
// Load config.json
|
||||||
|
export async function getConfig(): Promise<AppConfig> {
|
||||||
|
if (configCache) return configCache
|
||||||
|
const res = await fetch('/config.json')
|
||||||
|
configCache = await res.json()
|
||||||
|
return configCache!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load networks.json
|
||||||
|
export async function getNetworks(): Promise<Networks> {
|
||||||
|
if (networksCache) return networksCache
|
||||||
|
const res = await fetch('/networks.json')
|
||||||
|
networksCache = await res.json()
|
||||||
|
return networksCache!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve handle to DID (try all networks)
|
||||||
|
export async function resolveHandle(handle: string): Promise<string | null> {
|
||||||
|
const networks = await getNetworks()
|
||||||
|
|
||||||
|
// Try each network until one succeeds
|
||||||
|
for (const network of Object.values(networks)) {
|
||||||
|
try {
|
||||||
|
const host = network.bsky.replace('https://', '')
|
||||||
|
const url = `${xrpcUrl(host, comAtprotoIdentity.resolveHandle)}?handle=${handle}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.did
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try next network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get PDS endpoint for DID (try all networks)
|
||||||
|
export async function getPds(did: string): Promise<string | null> {
|
||||||
|
const networks = await getNetworks()
|
||||||
|
|
||||||
|
for (const network of Object.values(networks)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${network.plc}/${did}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const didDoc = await res.json()
|
||||||
|
const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')
|
||||||
|
if (service?.serviceEndpoint) {
|
||||||
|
return service.serviceEndpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try next network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load local profile
|
||||||
|
async function getLocalProfile(did: string): Promise<Profile | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/content/${did}/app.bsky.actor.profile/self.json`)
|
||||||
|
if (res.ok) return res.json()
|
||||||
|
} catch {
|
||||||
|
// Not found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profile (local first for admin, remote for others)
|
||||||
|
export async function getProfile(did: string, localFirst = true): Promise<Profile | null> {
|
||||||
|
if (localFirst) {
|
||||||
|
const local = await getLocalProfile(did)
|
||||||
|
if (local) return local
|
||||||
|
}
|
||||||
|
|
||||||
|
const pds = await getPds(did)
|
||||||
|
if (!pds) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = pds.replace('https://', '')
|
||||||
|
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=app.bsky.actor.profile&rkey=self`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) return res.json()
|
||||||
|
} catch {
|
||||||
|
// Failed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get avatar URL
|
||||||
|
export async function getAvatarUrl(did: string, profile: Profile): Promise<string | null> {
|
||||||
|
if (!profile.value.avatar) return null
|
||||||
|
|
||||||
|
const pds = await getPds(did)
|
||||||
|
if (!pds) return null
|
||||||
|
|
||||||
|
return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load local posts
|
||||||
|
async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
|
||||||
|
try {
|
||||||
|
const indexRes = await fetch(`/content/${did}/${collection}/index.json`)
|
||||||
|
if (indexRes.ok) {
|
||||||
|
const rkeys: string[] = await indexRes.json()
|
||||||
|
const posts: Post[] = []
|
||||||
|
for (const rkey of rkeys) {
|
||||||
|
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||||
|
if (res.ok) posts.push(await res.json())
|
||||||
|
}
|
||||||
|
return posts.sort((a, b) =>
|
||||||
|
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not found
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load posts (local first for admin, remote for others)
|
||||||
|
export async function getPosts(did: string, collection: string, localFirst = true): Promise<Post[]> {
|
||||||
|
if (localFirst) {
|
||||||
|
const local = await getLocalPosts(did, collection)
|
||||||
|
if (local.length > 0) return local
|
||||||
|
}
|
||||||
|
|
||||||
|
const pds = await getPds(did)
|
||||||
|
if (!pds) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = pds.replace('https://', '')
|
||||||
|
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) {
|
||||||
|
const data: ListRecordsResponse<Post> = await res.json()
|
||||||
|
return data.records.sort((a, b) =>
|
||||||
|
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get single post
|
||||||
|
export async function getPost(did: string, collection: string, rkey: string, localFirst = true): Promise<Post | null> {
|
||||||
|
if (localFirst) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||||
|
if (res.ok) return res.json()
|
||||||
|
} catch {
|
||||||
|
// Not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pds = await getPds(did)
|
||||||
|
if (!pds) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = pds.replace('https://', '')
|
||||||
|
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) return res.json()
|
||||||
|
} catch {
|
||||||
|
// Failed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe repo - get collections list
|
||||||
|
export async function describeRepo(did: string): Promise<string[]> {
|
||||||
|
// Try local first
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/content/${did}/describe.json`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.collections || []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote
|
||||||
|
const pds = await getPds(did)
|
||||||
|
if (!pds) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = pds.replace('https://', '')
|
||||||
|
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.collections || []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// List records from any collection
|
||||||
|
export async function listRecords(did: string, collection: string, limit = 50): Promise<{ uri: string; cid: string; value: unknown }[]> {
|
||||||
|
const pds = await getPds(did)
|
||||||
|
if (!pds) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = pds.replace('https://', '')
|
||||||
|
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=${limit}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.records || []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get single record from any collection
|
||||||
|
export async function getRecord(did: string, collection: string, rkey: string): Promise<{ uri: string; cid: string; value: unknown } | null> {
|
||||||
|
const pds = await getPds(did)
|
||||||
|
if (!pds) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = pds.replace('https://', '')
|
||||||
|
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) return res.json()
|
||||||
|
} catch {
|
||||||
|
// Failed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants for search
|
||||||
|
const SEARCH_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
|
// Get current network config
|
||||||
|
export async function getCurrentNetwork(): Promise<{ plc: string; bsky: string; web: string }> {
|
||||||
|
const config = await getConfig()
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const networkKey = config.network || 'bsky.social'
|
||||||
|
const network = networks[networkKey]
|
||||||
|
return {
|
||||||
|
plc: network?.plc || 'https://plc.directory',
|
||||||
|
bsky: network?.bsky || 'https://public.api.bsky.app',
|
||||||
|
web: network?.web || 'https://bsky.app'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get search endpoint for current network
|
||||||
|
async function getSearchEndpoint(): Promise<string> {
|
||||||
|
const network = await getCurrentNetwork()
|
||||||
|
return network.bsky
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search posts that link to a URL
|
||||||
|
export async function searchPostsForUrl(url: string): Promise<SearchPost[]> {
|
||||||
|
// Use current network's endpoint for search
|
||||||
|
const endpoint = await getSearchEndpoint()
|
||||||
|
|
||||||
|
// Extract search-friendly patterns from URL
|
||||||
|
// Note: Search API doesn't index paths well, so search by domain and filter client-side
|
||||||
|
const searchQueries: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
// Search by domain only (paths with / don't return results)
|
||||||
|
searchQueries.push(urlObj.host)
|
||||||
|
} catch {
|
||||||
|
searchQueries.push(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPosts: SearchPost[] = []
|
||||||
|
const seenUris = new Set<string>()
|
||||||
|
|
||||||
|
for (const query of searchQueries) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
|
||||||
|
{ signal: controller.signal }
|
||||||
|
)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!res.ok) continue
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const posts = (data.posts || []).filter((post: SearchPost) => {
|
||||||
|
const embedUri = (post.record as { embed?: { external?: { uri?: string } } })?.embed?.external?.uri
|
||||||
|
const text = (post.record as { text?: string })?.text || ''
|
||||||
|
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
if (!seenUris.has(post.uri)) {
|
||||||
|
seenUris.add(post.uri)
|
||||||
|
allPosts.push(post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Timeout or network error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date (newest first)
|
||||||
|
allPosts.sort((a, b) => {
|
||||||
|
const aDate = (a.record as { createdAt?: string })?.createdAt || ''
|
||||||
|
const bDate = (b.record as { createdAt?: string })?.createdAt || ''
|
||||||
|
return new Date(bDate).getTime() - new Date(aDate).getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
return allPosts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search post type
|
||||||
|
export interface SearchPost {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
author: {
|
||||||
|
did: string
|
||||||
|
handle: string
|
||||||
|
displayName?: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
record: unknown
|
||||||
|
}
|
||||||
293
src/web/lib/auth.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
||||||
|
import { Agent } from '@atproto/api'
|
||||||
|
import { getNetworks } from './api'
|
||||||
|
|
||||||
|
let oauthClient: BrowserOAuthClient | null = null
|
||||||
|
let agent: Agent | null = null
|
||||||
|
let sessionDid: string | null = null
|
||||||
|
let sessionHandle: string | null = null
|
||||||
|
let currentNetworkConfig: { bsky: string; plc: string } | null = null
|
||||||
|
|
||||||
|
// Get client ID based on environment
|
||||||
|
function getClientId(): string {
|
||||||
|
const host = window.location.host
|
||||||
|
|
||||||
|
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||||
|
const port = window.location.port || '5173'
|
||||||
|
const redirectUri = `http://127.0.0.1:${port}/`
|
||||||
|
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${window.location.origin}/client-metadata.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set network config (call before login)
|
||||||
|
export async function setNetworkConfig(handle: string): Promise<void> {
|
||||||
|
const networks = await getNetworks()
|
||||||
|
|
||||||
|
for (const [domain, network] of Object.entries(networks)) {
|
||||||
|
if (handle.endsWith(`.${domain}`)) {
|
||||||
|
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
||||||
|
oauthClient = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check syui.ai -> syu.is
|
||||||
|
if (handle.endsWith('.syui.ai')) {
|
||||||
|
const network = networks['syu.is']
|
||||||
|
if (network) {
|
||||||
|
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
||||||
|
oauthClient = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to first network
|
||||||
|
const first = Object.values(networks)[0]
|
||||||
|
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
|
||||||
|
oauthClient = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize OAuth client
|
||||||
|
async function initOAuthClient(): Promise<BrowserOAuthClient> {
|
||||||
|
if (oauthClient) return oauthClient
|
||||||
|
|
||||||
|
const handleResolver = currentNetworkConfig?.bsky || 'https://bsky.social'
|
||||||
|
const plcDirectoryUrl = currentNetworkConfig?.plc || 'https://plc.directory'
|
||||||
|
|
||||||
|
oauthClient = await BrowserOAuthClient.load({
|
||||||
|
clientId: getClientId(),
|
||||||
|
handleResolver,
|
||||||
|
plcDirectoryUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
return oauthClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login with handle
|
||||||
|
export async function login(handle: string): Promise<void> {
|
||||||
|
await setNetworkConfig(handle)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await initOAuthClient()
|
||||||
|
await client.signIn(handle, {
|
||||||
|
scope: 'atproto transition:generic'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Login failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OAuth callback
|
||||||
|
export async function handleCallback(): Promise<string | null> {
|
||||||
|
// Check query params first, then hash fragment
|
||||||
|
let params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
if (!params.has('code') && !params.has('state')) {
|
||||||
|
// Try hash fragment
|
||||||
|
if (window.location.hash && window.location.hash.length > 1) {
|
||||||
|
params = new URLSearchParams(window.location.hash.slice(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.has('code') && !params.has('state')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Detect network from issuer (iss param) and set config before init
|
||||||
|
const iss = params.get('iss') || ''
|
||||||
|
if (iss && !currentNetworkConfig) {
|
||||||
|
const networks = await getNetworks()
|
||||||
|
for (const [domain, network] of Object.entries(networks)) {
|
||||||
|
if (iss.includes(domain)) {
|
||||||
|
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await initOAuthClient()
|
||||||
|
|
||||||
|
// Initialize client to restore state from storage
|
||||||
|
await client.init()
|
||||||
|
|
||||||
|
const result = await client.callback(params)
|
||||||
|
sessionDid = result.session.did
|
||||||
|
|
||||||
|
// Create agent and get handle
|
||||||
|
agent = new Agent(result.session)
|
||||||
|
try {
|
||||||
|
const profile = await agent.getProfile({ actor: sessionDid })
|
||||||
|
sessionHandle = profile.data.handle
|
||||||
|
} catch {
|
||||||
|
// Could not get handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear URL params and hash
|
||||||
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
|
|
||||||
|
return sessionDid
|
||||||
|
} catch (e) {
|
||||||
|
console.error('OAuth callback error:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
// Clear module state
|
||||||
|
sessionDid = null
|
||||||
|
sessionHandle = null
|
||||||
|
agent = null
|
||||||
|
oauthClient = null
|
||||||
|
currentNetworkConfig = null
|
||||||
|
|
||||||
|
// Clear all storage
|
||||||
|
sessionStorage.clear()
|
||||||
|
localStorage.clear()
|
||||||
|
|
||||||
|
// Clear IndexedDB (used by OAuth client)
|
||||||
|
try {
|
||||||
|
const databases = await indexedDB.databases()
|
||||||
|
for (const db of databases) {
|
||||||
|
if (db.name) {
|
||||||
|
indexedDB.deleteDatabase(db.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// IndexedDB.databases() not supported in some browsers
|
||||||
|
console.warn('Could not clear IndexedDB:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore session from storage
|
||||||
|
export async function restoreSession(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Try to initialize with default network first
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const first = Object.values(networks)[0]
|
||||||
|
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
|
||||||
|
|
||||||
|
const client = await initOAuthClient()
|
||||||
|
const result = await client.init()
|
||||||
|
|
||||||
|
if (result?.session) {
|
||||||
|
sessionDid = result.session.did
|
||||||
|
|
||||||
|
// Create agent and get handle
|
||||||
|
agent = new Agent(result.session)
|
||||||
|
try {
|
||||||
|
const profile = await agent.getProfile({ actor: sessionDid })
|
||||||
|
sessionHandle = profile.data.handle
|
||||||
|
} catch {
|
||||||
|
// Could not get handle
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionDid
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail - no session to restore
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if logged in
|
||||||
|
export function isLoggedIn(): boolean {
|
||||||
|
return sessionDid !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logged in DID
|
||||||
|
export function getLoggedInDid(): string | null {
|
||||||
|
return sessionDid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logged in handle
|
||||||
|
export function getLoggedInHandle(): string | null {
|
||||||
|
return sessionHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get agent
|
||||||
|
export function getAgent(): Agent | null {
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create post
|
||||||
|
export async function createPost(
|
||||||
|
collection: string,
|
||||||
|
title: string,
|
||||||
|
content: string
|
||||||
|
): Promise<{ uri: string; cid: string } | null> {
|
||||||
|
if (!agent) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await agent.com.atproto.repo.createRecord({
|
||||||
|
repo: agent.assertDid,
|
||||||
|
collection,
|
||||||
|
record: {
|
||||||
|
$type: collection,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { uri: result.data.uri, cid: result.data.cid }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create post error:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update post
|
||||||
|
export async function updatePost(
|
||||||
|
collection: string,
|
||||||
|
rkey: string,
|
||||||
|
title: string,
|
||||||
|
content: string
|
||||||
|
): Promise<{ uri: string; cid: string } | null> {
|
||||||
|
if (!agent) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await agent.com.atproto.repo.putRecord({
|
||||||
|
repo: agent.assertDid,
|
||||||
|
collection,
|
||||||
|
rkey,
|
||||||
|
record: {
|
||||||
|
$type: collection,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { uri: result.data.uri, cid: result.data.cid }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update post error:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete record
|
||||||
|
export async function deleteRecord(
|
||||||
|
collection: string,
|
||||||
|
rkey: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!agent) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agent.com.atproto.repo.deleteRecord({
|
||||||
|
repo: agent.assertDid,
|
||||||
|
collection,
|
||||||
|
rkey,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete record error:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/web/lib/markdown.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { marked } from 'marked'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
// Configure marked
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Custom renderer for syntax highlighting
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
|
||||||
|
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
const highlighted = hljs.highlight(text, { language: lang }).value
|
||||||
|
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`
|
||||||
|
}
|
||||||
|
const escaped = escapeHtml(text)
|
||||||
|
return `<pre><code>${escaped}</code></pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.use({ renderer })
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render markdown to HTML
|
||||||
|
export function renderMarkdown(content: string): string {
|
||||||
|
return marked(content) as string
|
||||||
|
}
|
||||||
103
src/web/lib/router.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export interface Route {
|
||||||
|
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
|
||||||
|
handle?: string
|
||||||
|
rkey?: string
|
||||||
|
service?: string
|
||||||
|
collection?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse current URL to route
|
||||||
|
export function parseRoute(): Route {
|
||||||
|
const path = window.location.pathname
|
||||||
|
|
||||||
|
// Home: / or /app
|
||||||
|
if (path === '/' || path === '' || path === '/app' || path === '/app/') {
|
||||||
|
return { type: 'home' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT-Browser main: /@handle/at or /@handle/at/
|
||||||
|
const atBrowserMatch = path.match(/^\/@([^/]+)\/at\/?$/)
|
||||||
|
if (atBrowserMatch) {
|
||||||
|
return { type: 'atbrowser', handle: atBrowserMatch[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT-Browser service: /@handle/at/service/domain.tld
|
||||||
|
const serviceMatch = path.match(/^\/@([^/]+)\/at\/service\/([^/]+)$/)
|
||||||
|
if (serviceMatch) {
|
||||||
|
return { type: 'service', handle: serviceMatch[1], service: decodeURIComponent(serviceMatch[2]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT-Browser collection: /@handle/at/collection/namespace.name
|
||||||
|
const collectionMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)$/)
|
||||||
|
if (collectionMatch) {
|
||||||
|
return { type: 'collection', handle: collectionMatch[1], collection: collectionMatch[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT-Browser record: /@handle/at/collection/namespace.name/rkey
|
||||||
|
const recordMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)\/([^/]+)$/)
|
||||||
|
if (recordMatch) {
|
||||||
|
return { type: 'record', handle: recordMatch[1], collection: recordMatch[2], rkey: recordMatch[3] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// User page: /@handle or /@handle/
|
||||||
|
const userMatch = path.match(/^\/@([^/]+)\/?$/)
|
||||||
|
if (userMatch) {
|
||||||
|
return { type: 'user', handle: userMatch[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post form page: /@handle/at/post
|
||||||
|
const postPageMatch = path.match(/^\/@([^/]+)\/at\/post\/?$/)
|
||||||
|
if (postPageMatch) {
|
||||||
|
return { type: 'postpage', handle: postPageMatch[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post detail page: /@handle/rkey (for config.collection)
|
||||||
|
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
|
||||||
|
if (postMatch) {
|
||||||
|
return { type: 'post', handle: postMatch[1], rkey: postMatch[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to home
|
||||||
|
return { type: 'home' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to a route
|
||||||
|
export function navigate(route: Route): void {
|
||||||
|
let path = '/'
|
||||||
|
|
||||||
|
if (route.type === 'user' && route.handle) {
|
||||||
|
path = `/@${route.handle}`
|
||||||
|
} else if (route.type === 'postpage' && route.handle) {
|
||||||
|
path = `/@${route.handle}/at/post`
|
||||||
|
} else if (route.type === 'post' && route.handle && route.rkey) {
|
||||||
|
path = `/@${route.handle}/${route.rkey}`
|
||||||
|
} else if (route.type === 'atbrowser' && route.handle) {
|
||||||
|
path = `/@${route.handle}/at`
|
||||||
|
} else if (route.type === 'service' && route.handle && route.service) {
|
||||||
|
path = `/@${route.handle}/at/service/${encodeURIComponent(route.service)}`
|
||||||
|
} else if (route.type === 'collection' && route.handle && route.collection) {
|
||||||
|
path = `/@${route.handle}/at/collection/${route.collection}`
|
||||||
|
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
|
||||||
|
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.pushState({}, '', path)
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to route changes
|
||||||
|
export function onRouteChange(callback: (route: Route) => void): void {
|
||||||
|
const handler = () => callback(parseRoute())
|
||||||
|
window.addEventListener('popstate', handler)
|
||||||
|
|
||||||
|
// Handle link clicks
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const anchor = target.closest('a')
|
||||||
|
if (anchor && anchor.href.startsWith(window.location.origin)) {
|
||||||
|
e.preventDefault()
|
||||||
|
window.history.pushState({}, '', anchor.href)
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
397
src/web/main.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import './styles/main.css'
|
||||||
|
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
|
||||||
|
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
|
||||||
|
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
|
||||||
|
import { renderHeader } from './components/header'
|
||||||
|
import { renderProfile } from './components/profile'
|
||||||
|
import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
|
||||||
|
import { renderPostForm, setupPostForm } from './components/postform'
|
||||||
|
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
|
||||||
|
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
|
||||||
|
import { renderFooter } from './components/footer'
|
||||||
|
import { showLoading, hideLoading } from './components/loading'
|
||||||
|
|
||||||
|
const app = document.getElementById('app')!
|
||||||
|
|
||||||
|
let currentHandle = ''
|
||||||
|
|
||||||
|
// Filter collections by service domain
|
||||||
|
function filterCollectionsByService(collections: string[], service: string): string[] {
|
||||||
|
return collections.filter(col => {
|
||||||
|
const parts = col.split('.')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const colService = `${parts[1]}.${parts[0]}`
|
||||||
|
return colService === service
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get web URL for handle from networks
|
||||||
|
async function getWebUrl(handle: string): Promise<string | undefined> {
|
||||||
|
const networks = await getNetworks()
|
||||||
|
// Check each network for matching handle domain
|
||||||
|
for (const [domain, network] of Object.entries(networks)) {
|
||||||
|
// Direct domain match (e.g., handle.syu.is -> syu.is)
|
||||||
|
if (handle.endsWith(`.${domain}`)) {
|
||||||
|
return network.web
|
||||||
|
}
|
||||||
|
// Check if handle domain matches network's web domain (e.g., syui.syui.ai -> syu.is via web: syu.is)
|
||||||
|
const webDomain = network.web?.replace(/^https?:\/\//, '')
|
||||||
|
if (webDomain && handle.endsWith(`.${webDomain}`)) {
|
||||||
|
return network.web
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for syui.ai handles -> syu.is network
|
||||||
|
if (handle.endsWith('.syui.ai')) {
|
||||||
|
return networks['syu.is']?.web
|
||||||
|
}
|
||||||
|
// Default to first network's web
|
||||||
|
const firstNetwork = Object.values(networks)[0]
|
||||||
|
return firstNetwork?.web
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(route: Route): Promise<void> {
|
||||||
|
showLoading(app)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getConfig()
|
||||||
|
|
||||||
|
// Apply theme color from config
|
||||||
|
if (config.color) {
|
||||||
|
document.documentElement.style.setProperty('--btn-color', config.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OAuth callback if present (check both ? and #)
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
|
||||||
|
if (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state')) {
|
||||||
|
await handleCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore session from storage
|
||||||
|
await restoreSession()
|
||||||
|
|
||||||
|
// Redirect logged-in user from root to their user page
|
||||||
|
if (route.type === 'home' && isLoggedIn()) {
|
||||||
|
const loggedInHandle = getLoggedInHandle()
|
||||||
|
if (loggedInHandle) {
|
||||||
|
navigate({ type: 'user', handle: loggedInHandle })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine handle and whether to use local data
|
||||||
|
let handle: string
|
||||||
|
let localFirst: boolean
|
||||||
|
|
||||||
|
if (route.type === 'home') {
|
||||||
|
handle = config.handle
|
||||||
|
localFirst = true
|
||||||
|
} else if (route.handle) {
|
||||||
|
handle = route.handle
|
||||||
|
localFirst = handle === config.handle
|
||||||
|
} else {
|
||||||
|
handle = config.handle
|
||||||
|
localFirst = true
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHandle = handle
|
||||||
|
|
||||||
|
// Resolve handle to DID
|
||||||
|
const did = await resolveHandle(handle)
|
||||||
|
|
||||||
|
if (!did) {
|
||||||
|
app.innerHTML = `
|
||||||
|
${renderHeader(handle)}
|
||||||
|
<div class="error">Could not resolve handle: ${handle}</div>
|
||||||
|
${renderFooter(handle)}
|
||||||
|
`
|
||||||
|
setupEventHandlers()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profile
|
||||||
|
const profile = await getProfile(did, localFirst)
|
||||||
|
const webUrl = await getWebUrl(handle)
|
||||||
|
|
||||||
|
// Load posts to check for translations
|
||||||
|
const posts = await getPosts(did, config.collection, localFirst)
|
||||||
|
|
||||||
|
// Collect available languages from posts
|
||||||
|
const availableLangs = new Set<string>()
|
||||||
|
for (const post of posts) {
|
||||||
|
// Add original language (default: ja for Japanese posts)
|
||||||
|
const postLang = post.value.lang || 'ja'
|
||||||
|
availableLangs.add(postLang)
|
||||||
|
// Add translation languages
|
||||||
|
if (post.value.translations) {
|
||||||
|
for (const lang of Object.keys(post.value.translations)) {
|
||||||
|
availableLangs.add(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const langList = Array.from(availableLangs)
|
||||||
|
|
||||||
|
// Build page
|
||||||
|
let html = renderHeader(handle)
|
||||||
|
|
||||||
|
// Mode tabs (Blog/Browser/Post/PDS)
|
||||||
|
const activeTab = route.type === 'postpage' ? 'post' :
|
||||||
|
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
|
||||||
|
html += renderModeTabs(handle, activeTab)
|
||||||
|
|
||||||
|
// Profile section
|
||||||
|
if (profile) {
|
||||||
|
html += await renderProfile(did, profile, handle, webUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if logged-in user owns this content
|
||||||
|
const loggedInDid = getLoggedInDid()
|
||||||
|
const isOwner = isLoggedIn() && loggedInDid === did
|
||||||
|
|
||||||
|
// Content section based on route type
|
||||||
|
if (route.type === 'record' && route.collection && route.rkey) {
|
||||||
|
// AT-Browser: Single record view
|
||||||
|
const record = await getRecord(did, route.collection, route.rkey)
|
||||||
|
if (record) {
|
||||||
|
html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>`
|
||||||
|
} else {
|
||||||
|
html += `<div id="content" class="error">Record not found</div>`
|
||||||
|
}
|
||||||
|
html += `<nav class="back-nav"><a href="/@${handle}/at/collection/${route.collection}">${route.collection}</a></nav>`
|
||||||
|
|
||||||
|
} else if (route.type === 'collection' && route.collection) {
|
||||||
|
// AT-Browser: Collection records list
|
||||||
|
const records = await listRecords(did, route.collection)
|
||||||
|
html += `<div id="content">${renderRecordList(records, handle, route.collection)}</div>`
|
||||||
|
const parts = route.collection.split('.')
|
||||||
|
const service = parts.length >= 2 ? `${parts[1]}.${parts[0]}` : ''
|
||||||
|
html += `<nav class="back-nav"><a href="/@${handle}/at/service/${encodeURIComponent(service)}">${service}</a></nav>`
|
||||||
|
|
||||||
|
} else if (route.type === 'service' && route.service) {
|
||||||
|
// AT-Browser: Service collections list
|
||||||
|
const collections = await describeRepo(did)
|
||||||
|
const filtered = filterCollectionsByService(collections, route.service)
|
||||||
|
html += `<div id="content">${renderCollectionList(filtered, handle, route.service)}</div>`
|
||||||
|
html += `<nav class="back-nav"><a href="/@${handle}/at">at</a></nav>`
|
||||||
|
|
||||||
|
} else if (route.type === 'atbrowser') {
|
||||||
|
// AT-Browser: Main view with server info + service list
|
||||||
|
const pds = await getPds(did)
|
||||||
|
const collections = await describeRepo(did)
|
||||||
|
|
||||||
|
html += `<div id="browser">`
|
||||||
|
html += renderServerInfo(did, pds)
|
||||||
|
html += renderServiceList(collections, handle)
|
||||||
|
html += `</div>`
|
||||||
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
|
|
||||||
|
} else if (route.type === 'post' && route.rkey) {
|
||||||
|
// Post detail (config.collection with markdown)
|
||||||
|
const post = await getPost(did, config.collection, route.rkey, localFirst)
|
||||||
|
html += renderLangSelector(langList)
|
||||||
|
if (post) {
|
||||||
|
html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>`
|
||||||
|
} else {
|
||||||
|
html += `<div id="content" class="error">Post not found</div>`
|
||||||
|
}
|
||||||
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
|
|
||||||
|
} else if (route.type === 'postpage') {
|
||||||
|
// Post form page
|
||||||
|
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
|
||||||
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// User page: compact collection buttons + posts
|
||||||
|
const collections = await describeRepo(did)
|
||||||
|
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
|
||||||
|
|
||||||
|
// Language selector above content
|
||||||
|
html += renderLangSelector(langList)
|
||||||
|
|
||||||
|
// Use pre-loaded posts
|
||||||
|
html += `<div id="content">${renderPostList(posts, handle)}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
html += renderFooter(handle)
|
||||||
|
|
||||||
|
app.innerHTML = html
|
||||||
|
hideLoading(app)
|
||||||
|
setupEventHandlers()
|
||||||
|
|
||||||
|
// Setup mode tabs (PDS selector + Lang selector)
|
||||||
|
await setupModeTabs(
|
||||||
|
(_network) => {
|
||||||
|
// Refresh when network is changed
|
||||||
|
render(parseRoute())
|
||||||
|
},
|
||||||
|
langList,
|
||||||
|
(_lang) => {
|
||||||
|
// Refresh when language is changed
|
||||||
|
render(parseRoute())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup post form on postpage
|
||||||
|
if (route.type === 'postpage' && isLoggedIn()) {
|
||||||
|
setupPostForm(config.collection, () => {
|
||||||
|
// Navigate to user page on success
|
||||||
|
navigate({ type: 'user', handle })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup record delete button
|
||||||
|
if (isOwner) {
|
||||||
|
setupRecordDelete(handle, route)
|
||||||
|
setupPostEdit(config.collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup post detail (translation toggle, discussion)
|
||||||
|
if (route.type === 'post') {
|
||||||
|
const contentEl = document.getElementById('content')
|
||||||
|
if (contentEl) {
|
||||||
|
setupPostDetail(contentEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Render error:', error)
|
||||||
|
app.innerHTML = `
|
||||||
|
${renderHeader(currentHandle)}
|
||||||
|
<div class="error">Error: ${error}</div>
|
||||||
|
${renderFooter(currentHandle)}
|
||||||
|
`
|
||||||
|
hideLoading(app)
|
||||||
|
setupEventHandlers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventHandlers(): void {
|
||||||
|
// Header form
|
||||||
|
const form = document.getElementById('header-form') as HTMLFormElement
|
||||||
|
const input = document.getElementById('header-input') as HTMLInputElement
|
||||||
|
|
||||||
|
form?.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const handle = input.value.trim()
|
||||||
|
if (handle) {
|
||||||
|
navigate({ type: 'user', handle })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Login button
|
||||||
|
const loginBtn = document.getElementById('login-btn')
|
||||||
|
loginBtn?.addEventListener('click', async () => {
|
||||||
|
const handle = input.value.trim() || currentHandle
|
||||||
|
if (handle) {
|
||||||
|
try {
|
||||||
|
await login(handle)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Login failed:', e)
|
||||||
|
alert('Login failed. Please check your handle.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Please enter a handle first.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Logout button
|
||||||
|
const logoutBtn = document.getElementById('logout-btn')
|
||||||
|
logoutBtn?.addEventListener('click', async () => {
|
||||||
|
await logout()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup record delete button
|
||||||
|
function setupRecordDelete(handle: string, _route: Route): void {
|
||||||
|
const deleteBtn = document.getElementById('record-delete-btn')
|
||||||
|
if (!deleteBtn) return
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
const collection = deleteBtn.getAttribute('data-collection')
|
||||||
|
const rkey = deleteBtn.getAttribute('data-rkey')
|
||||||
|
|
||||||
|
if (!collection || !rkey) return
|
||||||
|
|
||||||
|
if (!confirm('Are you sure you want to delete this record?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
deleteBtn.textContent = 'Deleting...'
|
||||||
|
;(deleteBtn as HTMLButtonElement).disabled = true
|
||||||
|
|
||||||
|
await deleteRecord(collection, rkey)
|
||||||
|
|
||||||
|
// Navigate back to collection list
|
||||||
|
navigate({ type: 'collection', handle, collection })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
alert('Delete failed: ' + err)
|
||||||
|
deleteBtn.textContent = 'Delete'
|
||||||
|
;(deleteBtn as HTMLButtonElement).disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup post edit form
|
||||||
|
function setupPostEdit(collection: string): void {
|
||||||
|
const editBtn = document.getElementById('post-edit-btn')
|
||||||
|
const editForm = document.getElementById('post-edit-form')
|
||||||
|
const postDisplay = document.getElementById('post-display')
|
||||||
|
const cancelBtn = document.getElementById('post-edit-cancel')
|
||||||
|
const saveBtn = document.getElementById('post-edit-save')
|
||||||
|
const titleInput = document.getElementById('post-edit-title') as HTMLInputElement
|
||||||
|
const contentInput = document.getElementById('post-edit-content') as HTMLTextAreaElement
|
||||||
|
|
||||||
|
if (!editBtn || !editForm) return
|
||||||
|
|
||||||
|
// Show edit form
|
||||||
|
editBtn.addEventListener('click', () => {
|
||||||
|
if (postDisplay) postDisplay.style.display = 'none'
|
||||||
|
editForm.style.display = 'block'
|
||||||
|
editBtn.style.display = 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cancel edit
|
||||||
|
cancelBtn?.addEventListener('click', () => {
|
||||||
|
editForm.style.display = 'none'
|
||||||
|
if (postDisplay) postDisplay.style.display = ''
|
||||||
|
editBtn.style.display = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save edit
|
||||||
|
saveBtn?.addEventListener('click', async () => {
|
||||||
|
const rkey = saveBtn.getAttribute('data-rkey')
|
||||||
|
if (!rkey || !titleInput || !contentInput) return
|
||||||
|
|
||||||
|
const title = titleInput.value.trim()
|
||||||
|
const content = contentInput.value.trim()
|
||||||
|
|
||||||
|
if (!title || !content) {
|
||||||
|
alert('Title and content are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveBtn.textContent = 'Saving...'
|
||||||
|
;(saveBtn as HTMLButtonElement).disabled = true
|
||||||
|
|
||||||
|
await updatePost(collection, rkey, title, content)
|
||||||
|
|
||||||
|
// Refresh the page
|
||||||
|
render(parseRoute())
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err)
|
||||||
|
alert('Update failed: ' + err)
|
||||||
|
saveBtn.textContent = 'Save'
|
||||||
|
;(saveBtn as HTMLButtonElement).disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
render(parseRoute())
|
||||||
|
|
||||||
|
// Handle route changes
|
||||||
|
onRouteChange(render)
|
||||||
2190
src/web/styles/main.css
Normal file
64
src/web/types.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Config types
|
||||||
|
export interface AppConfig {
|
||||||
|
title: string
|
||||||
|
handle: string
|
||||||
|
collection: string
|
||||||
|
network: string
|
||||||
|
color: string
|
||||||
|
siteUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Networks {
|
||||||
|
[domain: string]: {
|
||||||
|
plc: string
|
||||||
|
bsky: string
|
||||||
|
web: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATProto types
|
||||||
|
export interface DescribeRepo {
|
||||||
|
did: string
|
||||||
|
handle: string
|
||||||
|
collections: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
cid: string
|
||||||
|
uri: string
|
||||||
|
value: {
|
||||||
|
$type: string
|
||||||
|
avatar?: {
|
||||||
|
$type: string
|
||||||
|
mimeType: string
|
||||||
|
ref: { $link: string }
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
displayName?: string
|
||||||
|
description?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
cid: string
|
||||||
|
uri: string
|
||||||
|
value: {
|
||||||
|
$type: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
lang?: string
|
||||||
|
translations?: {
|
||||||
|
[lang: string]: {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListRecordsResponse<T> {
|
||||||
|
records: T[]
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/web/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/web/**/*.ts"]
|
||||||
|
}
|
||||||
20
vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'src/web',
|
||||||
|
publicDir: '../../public',
|
||||||
|
build: {
|
||||||
|
outDir: '../../dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src/web'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||