Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
743c1096ae
|
|||
|
ea3652aec6
|
@@ -1,3 +0,0 @@
|
|||||||
# LMS Translation API
|
|
||||||
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
|
||||||
TRANSLATE_MODEL=plamo-2-translate
|
|
||||||
36
.github/workflows/cf-pages.yml
vendored
36
.github/workflows/cf-pages.yml
vendored
@@ -1,36 +0,0 @@
|
|||||||
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 }}
|
|
||||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -2,7 +2,7 @@ name: Deploy to GitHub Pages
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [min]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
24
Cargo.toml
24
Cargo.toml
@@ -1,24 +0,0 @@
|
|||||||
[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
13
index.html
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"title": "syui.ai",
|
"title": "ailog",
|
||||||
"handle": "syui.syui.ai",
|
"handle": "syui.ai",
|
||||||
"collection": "ai.syui.log.post",
|
"collection": "ai.syui.log.post",
|
||||||
"network": "syu.is",
|
"network": "bsky.social",
|
||||||
"color": "#EF454A",
|
"color": "#EF454A",
|
||||||
"siteUrl": "https://syui.ai"
|
"siteUrl": "https://syui.github.io",
|
||||||
|
"oauth": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"cid": "bafyreigwaeqfluw7btvnmxfogd77gtk4efwomvjsvq2yxmmxr2665zwwbi",
|
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
|
||||||
"value": {
|
|
||||||
"$type": "ai.syui.log.post",
|
|
||||||
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする",
|
|
||||||
"createdAt": "2026-01-18T08:31:52.715Z",
|
|
||||||
"title": "ailogを作り直した",
|
|
||||||
"translations": {
|
|
||||||
"en": {
|
|
||||||
"content": "## About ailog\n\nA site generator that integrates with atproto.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser architecture\n2. Uses atproto oAuth for login\n3. Allows posting articles through the logged-in account",
|
|
||||||
"title": "recreated ailog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lang": "ja"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
["3mchqlshygs2s"]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy",
|
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
|
|
||||||
"value": {
|
|
||||||
"$type": "app.bsky.actor.profile",
|
|
||||||
"avatar": {
|
|
||||||
"$type": "blob",
|
|
||||||
"mimeType": "image/jpeg",
|
|
||||||
"ref": {
|
|
||||||
"$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u"
|
|
||||||
},
|
|
||||||
"size": 166370
|
|
||||||
},
|
|
||||||
"createdAt": "2025-09-19T06:17:42Z",
|
|
||||||
"description": "",
|
|
||||||
"displayName": "syui"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"collections": [
|
|
||||||
"ai.syui.log.post",
|
|
||||||
"app.bsky.actor.profile",
|
|
||||||
"app.bsky.feed.post",
|
|
||||||
"app.bsky.feed.repost",
|
|
||||||
"app.bsky.graph.follow",
|
|
||||||
"chat.bsky.actor.declaration",
|
|
||||||
"com.atproto.lexicon.schema"
|
|
||||||
],
|
|
||||||
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
|
||||||
"handle": "syui.syui.ai"
|
|
||||||
}
|
|
||||||
178
readme.md
178
readme.md
@@ -11,181 +11,3 @@ $ cat public/config.json
|
|||||||
$ npm run dev
|
$ npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## oauth
|
|
||||||
|
|
||||||
Use ATProto OAuth to login from the browser and create, edit, or delete posts.
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
#### 1. Edit client-metadata.json
|
|
||||||
|
|
||||||
Modify `public/client-metadata.json` with your own domain:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"client_id": "https://example.com/client-metadata.json",
|
|
||||||
"client_name": "example.com",
|
|
||||||
"client_uri": "https://example.com",
|
|
||||||
"redirect_uris": ["https://example.com/"],
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Required changes:**
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `client_id` | URL of this file. Must be `https://yourdomain.com/client-metadata.json` |
|
|
||||||
| `client_name` | App name (shown on auth screen) |
|
|
||||||
| `client_uri` | Your site URL |
|
|
||||||
| `redirect_uris` | Redirect URL after OAuth. Use your site's root URL |
|
|
||||||
|
|
||||||
#### 2. Deploy the file
|
|
||||||
|
|
||||||
`client-metadata.json` must be publicly accessible at:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://yourdomain.com/client-metadata.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The ATProto PDS fetches this file during authentication, so it **must be accessible via public URL**.
|
|
||||||
|
|
||||||
#### 3. Local development
|
|
||||||
|
|
||||||
No configuration needed for local development (localhost/127.0.0.1). The code automatically uses ATProto's loopback client ID:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost?redirect_uri=http://127.0.0.1:5173/&scope=atproto%20transition%3Ageneric
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Network configuration
|
|
||||||
|
|
||||||
To support multiple PDS servers, define networks in `public/network.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bsky.social": {
|
|
||||||
"bsky": "https://bsky.social",
|
|
||||||
"plc": "https://plc.directory"
|
|
||||||
},
|
|
||||||
"syu.is": {
|
|
||||||
"bsky": "https://bsky.syu.is",
|
|
||||||
"plc": "https://plc.syu.is",
|
|
||||||
"web": "https://syu.is"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The appropriate PDS is automatically selected based on the handle's domain.
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
|
|
||||||
- **Auth error**: Verify `client_id` matches the actual file URL
|
|
||||||
- **Redirect error**: Verify `redirect_uris` matches your site URL
|
|
||||||
- **CORS error**: Verify `client-metadata.json` is served with correct Content-Type
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod auth;
|
|
||||||
pub mod token;
|
|
||||||
pub mod post;
|
|
||||||
pub mod gen;
|
|
||||||
pub mod lang;
|
|
||||||
pub mod did;
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
//! 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 +0,0 @@
|
|||||||
pub mod translate;
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
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
150
src/main.rs
@@ -1,150 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
|
import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
|
||||||
|
|
||||||
export function renderHeader(currentHandle: string): string {
|
export function renderHeader(currentHandle: string, oauth: boolean = true): string {
|
||||||
const loggedIn = isLoggedIn()
|
const loggedIn = isLoggedIn()
|
||||||
const handle = getLoggedInHandle()
|
const handle = getLoggedInHandle()
|
||||||
|
|
||||||
const loginBtn = loggedIn
|
let loginBtn = ''
|
||||||
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout">${handle || 'logout'}</button>`
|
if (oauth) {
|
||||||
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login"><img src="/icon/user.svg" alt="Login" class="login-icon"></button>`
|
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 `
|
return `
|
||||||
<header id="header">
|
<header id="header">
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ async function render(route: Route): Promise<void> {
|
|||||||
document.title = config.title
|
document.title = config.title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check OAuth enabled
|
||||||
|
const oauthEnabled = config.oauth !== false
|
||||||
|
|
||||||
// Handle OAuth callback if present (check both ? and #)
|
// Handle OAuth callback if present (check both ? and #)
|
||||||
const searchParams = new URLSearchParams(window.location.search)
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
|
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
|
||||||
@@ -108,7 +111,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
|
|
||||||
if (!did) {
|
if (!did) {
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
${renderHeader(handle)}
|
${renderHeader(handle, oauthEnabled)}
|
||||||
<div class="error">Could not resolve handle: ${handle}</div>
|
<div class="error">Could not resolve handle: ${handle}</div>
|
||||||
${renderFooter(handle)}
|
${renderFooter(handle)}
|
||||||
`
|
`
|
||||||
@@ -139,7 +142,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
const langList = Array.from(availableLangs)
|
const langList = Array.from(availableLangs)
|
||||||
|
|
||||||
// Build page
|
// Build page
|
||||||
let html = renderHeader(handle)
|
let html = renderHeader(handle, oauthEnabled)
|
||||||
|
|
||||||
// Mode tabs (Blog/Browser/Post/PDS)
|
// Mode tabs (Blog/Browser/Post/PDS)
|
||||||
const activeTab = route.type === 'postpage' ? 'post' :
|
const activeTab = route.type === 'postpage' ? 'post' :
|
||||||
@@ -264,7 +267,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Render error:', error)
|
console.error('Render error:', error)
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
${renderHeader(currentHandle)}
|
${renderHeader(currentHandle, false)}
|
||||||
<div class="error">Error: ${error}</div>
|
<div class="error">Error: ${error}</div>
|
||||||
${renderFooter(currentHandle)}
|
${renderFooter(currentHandle)}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface AppConfig {
|
|||||||
network: string
|
network: string
|
||||||
color: string
|
color: string
|
||||||
siteUrl: string
|
siteUrl: string
|
||||||
|
oauth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Networks {
|
export interface Networks {
|
||||||
|
|||||||
Reference in New Issue
Block a user