fix
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,8 +1,9 @@
|
|||||||
dist
|
/dist
|
||||||
repos
|
/repos
|
||||||
|
/target
|
||||||
|
/CLAUDE.md
|
||||||
|
/.claude
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
CLAUDE.md
|
Cargo.lock
|
||||||
.claude
|
|
||||||
.env
|
.env
|
||||||
target
|
|
||||||
|
|||||||
18
Cargo.toml
18
Cargo.toml
@@ -2,4 +2,22 @@
|
|||||||
name = "ailog"
|
name = "ailog"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
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"
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
|
||||||
"cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
|
"cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
|
||||||
"value": {
|
|
||||||
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
|
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
||||||
|
"value": {
|
||||||
"$type": "ai.syui.log.post",
|
"$type": "ai.syui.log.post",
|
||||||
"title": "ailogを作り直した",
|
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
|
||||||
"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. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
"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. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
||||||
"createdAt": "2026-01-15T13:59:52.367Z",
|
"createdAt": "2026-01-15T13:59:52.367Z",
|
||||||
|
"title": "ailogを作り直した",
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": {
|
"en": {
|
||||||
"title": "recreated ailog",
|
"content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\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 as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
||||||
"content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\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 as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```"
|
"title": "recreated ailog"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
|
|
||||||
"cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy",
|
"cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy",
|
||||||
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
|
||||||
"value": {
|
"value": {
|
||||||
"$type": "app.bsky.actor.profile",
|
"$type": "app.bsky.actor.profile",
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
"ref": {
|
"ref": {
|
||||||
"$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u"
|
"$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u"
|
||||||
},
|
},
|
||||||
"mimeType": "image/jpeg",
|
|
||||||
"size": 166370
|
"size": 166370
|
||||||
},
|
},
|
||||||
"createdAt": "2025-09-19T06:17:42Z",
|
"createdAt": "2025-09-19T06:17:42Z",
|
||||||
|
|||||||
@@ -1,39 +1,13 @@
|
|||||||
{
|
{
|
||||||
"handle": "syui.syui.ai",
|
|
||||||
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
|
||||||
"didDoc": {
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/did/v1",
|
|
||||||
"https://w3id.org/security/multikey/v1",
|
|
||||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
|
||||||
],
|
|
||||||
"id": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
|
||||||
"alsoKnownAs": [
|
|
||||||
"at://syui.syui.ai"
|
|
||||||
],
|
|
||||||
"verificationMethod": [
|
|
||||||
{
|
|
||||||
"id": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y#atproto",
|
|
||||||
"type": "Multikey",
|
|
||||||
"controller": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
|
||||||
"publicKeyMultibase": "zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"service": [
|
|
||||||
{
|
|
||||||
"id": "#atproto_pds",
|
|
||||||
"type": "AtprotoPersonalDataServer",
|
|
||||||
"serviceEndpoint": "https://syu.is"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"collections": [
|
"collections": [
|
||||||
"ai.syui.log.post",
|
"ai.syui.log.post",
|
||||||
"app.bsky.actor.profile",
|
"app.bsky.actor.profile",
|
||||||
"app.bsky.feed.post",
|
"app.bsky.feed.post",
|
||||||
"app.bsky.feed.repost",
|
"app.bsky.feed.repost",
|
||||||
"app.bsky.graph.follow",
|
"app.bsky.graph.follow",
|
||||||
"chat.bsky.actor.declaration"
|
"chat.bsky.actor.declaration",
|
||||||
|
"com.atproto.lexicon.schema"
|
||||||
],
|
],
|
||||||
"handleIsCorrect": true
|
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
||||||
|
"handle": "syui.syui.ai"
|
||||||
}
|
}
|
||||||
13
index.html
Normal file
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>ailog</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>
|
||||||
23
readme.md
23
readme.md
@@ -152,13 +152,20 @@ curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collecti
|
|||||||
### Local (Static File)
|
### Local (Static File)
|
||||||
|
|
||||||
```
|
```
|
||||||
public/records/ai.syui.log.post/3xxx.json
|
content/
|
||||||
|
└── did:plc:xxx/
|
||||||
|
├── describe.json # describeRepo (special)
|
||||||
|
├── app.bsky.actor.profile/
|
||||||
|
│ └── self.json # {collection}/{rkey}.json
|
||||||
|
└── ai.syui.log.post/
|
||||||
|
└── 3xxx.json # {collection}/{rkey}.json
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
// content/did:plc:xxx/ai.syui.log.post/3xxx.json
|
||||||
{
|
{
|
||||||
"uri": "at://did:plc:xxx/ai.syui.log.post/3xxx",
|
"uri": "at://did:plc:xxx/ai.syui.log.post/3xxx",
|
||||||
"cid": "local",
|
"cid": "bafyrei...",
|
||||||
"value": {
|
"value": {
|
||||||
"title": "Hello World",
|
"title": "Hello World",
|
||||||
"content": "# Hello\n\nThis is my post.",
|
"content": "# Hello\n\nThis is my post.",
|
||||||
@@ -167,13 +174,23 @@ public/records/ai.syui.log.post/3xxx.json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ATProto API Reference
|
||||||
|
|
||||||
|
| API | Path | Description |
|
||||||
|
|-----|------|-------------|
|
||||||
|
| getRecord | `/xrpc/com.atproto.repo.getRecord` | Get single record |
|
||||||
|
| listRecords | `/xrpc/com.atproto.repo.listRecords` | List records in collection |
|
||||||
|
| describeRepo | `/xrpc/com.atproto.repo.describeRepo` | Get repo info + collections list |
|
||||||
|
|
||||||
|
See: [com.atproto.repo.describeRepo](https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo)
|
||||||
|
|
||||||
### Resolution Strategy
|
### Resolution Strategy
|
||||||
|
|
||||||
```
|
```
|
||||||
at-browser
|
at-browser
|
||||||
│
|
│
|
||||||
├── admin (config.json user)
|
├── admin (config.json user)
|
||||||
│ ├── 1. Check local: /records/{collection}/{rkey}.json
|
│ ├── 1. Check local: /content/{did}/{collection}/{rkey}.json
|
||||||
│ └── 2. Fallback to remote: PDS API
|
│ └── 2. Fallback to remote: PDS API
|
||||||
│
|
│
|
||||||
└── user (/@handle)
|
└── user (/@handle)
|
||||||
|
|||||||
96
src/auth.rs
Normal file
96
src/auth.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::token::{self, Session};
|
||||||
|
|
||||||
|
#[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 = format!("https://{}/xrpc/com.atproto.server.createSession", pds);
|
||||||
|
|
||||||
|
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 = format!("https://{}/xrpc/com.atproto.server.refreshSession", pds);
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
272
src/lib/api.ts
Normal file
272
src/lib/api.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// Types matching ATProto record format
|
||||||
|
export interface Config {
|
||||||
|
title: string
|
||||||
|
handle: string
|
||||||
|
collection: string
|
||||||
|
network: string
|
||||||
|
color: string
|
||||||
|
siteUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Networks {
|
||||||
|
[domain: string]: {
|
||||||
|
plc: string
|
||||||
|
bsky: string
|
||||||
|
web: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Links {
|
||||||
|
footer: Array<{ title: string; url: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for loaded data
|
||||||
|
let configCache: Config | null = null
|
||||||
|
let networksCache: Networks | null = null
|
||||||
|
let linksCache: Links | null = null
|
||||||
|
|
||||||
|
// Load config.json
|
||||||
|
export async function getConfig(): Promise<Config> {
|
||||||
|
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!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load links.json
|
||||||
|
export async function getLinks(): Promise<Links> {
|
||||||
|
if (linksCache) return linksCache
|
||||||
|
const res = await fetch('/links.json')
|
||||||
|
linksCache = await res.json()
|
||||||
|
return linksCache!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve handle to DID using local describe.json or remote API
|
||||||
|
export async function resolveHandle(handle: string): Promise<string | null> {
|
||||||
|
const config = await getConfig()
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const network = networks[config.network]
|
||||||
|
|
||||||
|
// Try remote resolution
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${network.bsky}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.did
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to searching local content
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get DID from local content directory
|
||||||
|
export async function getLocalDid(): Promise<string | null> {
|
||||||
|
const config = await getConfig()
|
||||||
|
|
||||||
|
// Try to resolve via API first
|
||||||
|
const did = await resolveHandle(config.handle)
|
||||||
|
if (did) return did
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load describe.json for a DID
|
||||||
|
export async function getDescribe(did: string): Promise<DescribeRepo | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/content/${did}/describe.json`)
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File not found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profile for a DID
|
||||||
|
export async function getProfile(did: string): Promise<Profile | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/content/${did}/app.bsky.actor.profile/self.json`)
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File not found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get avatar URL from profile
|
||||||
|
export async function getAvatarUrl(did: string, profile: Profile): Promise<string | null> {
|
||||||
|
if (!profile.value.avatar) return null
|
||||||
|
|
||||||
|
const config = await getConfig()
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const network = networks[config.network]
|
||||||
|
|
||||||
|
// Get PDS endpoint for this DID
|
||||||
|
try {
|
||||||
|
const plcRes = await fetch(`${network.plc}/${did}`)
|
||||||
|
if (plcRes.ok) {
|
||||||
|
const didDoc = await plcRes.json()
|
||||||
|
const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
|
||||||
|
if (pds) {
|
||||||
|
return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to bsky.social
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all posts from a collection
|
||||||
|
export async function listPosts(did: string, collection: string): Promise<Post[]> {
|
||||||
|
const posts: Post[] = []
|
||||||
|
|
||||||
|
// Try to load index.json which lists all rkeys
|
||||||
|
try {
|
||||||
|
const indexRes = await fetch(`/content/${did}/${collection}/index.json`)
|
||||||
|
if (indexRes.ok) {
|
||||||
|
const index: string[] = await indexRes.json()
|
||||||
|
for (const rkey of index) {
|
||||||
|
const postRes = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||||
|
if (postRes.ok) {
|
||||||
|
posts.push(await postRes.json())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posts.sort((a, b) =>
|
||||||
|
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No index file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: load from remote API
|
||||||
|
const config = await getConfig()
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const network = networks[config.network]
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get PDS endpoint
|
||||||
|
const plcRes = await fetch(`${network.plc}/${did}`)
|
||||||
|
if (plcRes.ok) {
|
||||||
|
const didDoc = await plcRes.json()
|
||||||
|
const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
|
||||||
|
if (pds) {
|
||||||
|
const recordsRes = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100`)
|
||||||
|
if (recordsRes.ok) {
|
||||||
|
const data = await recordsRes.json()
|
||||||
|
return data.records.map((r: { uri: string; cid: string; value: Post['value'] }) => ({
|
||||||
|
uri: r.uri,
|
||||||
|
cid: r.cid,
|
||||||
|
value: r.value
|
||||||
|
})).sort((a: Post, b: Post) =>
|
||||||
|
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Remote API failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a single post by rkey
|
||||||
|
export async function getPost(did: string, collection: string, rkey: string): Promise<Post | null> {
|
||||||
|
// Try local first
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: load from remote API
|
||||||
|
const config = await getConfig()
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const network = networks[config.network]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plcRes = await fetch(`${network.plc}/${did}`)
|
||||||
|
if (plcRes.ok) {
|
||||||
|
const didDoc = await plcRes.json()
|
||||||
|
const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
|
||||||
|
if (pds) {
|
||||||
|
const recordRes = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`)
|
||||||
|
if (recordRes.ok) {
|
||||||
|
return await recordRes.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Remote API failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get profile link URL
|
||||||
|
export async function getProfileUrl(did: string): Promise<string> {
|
||||||
|
const config = await getConfig()
|
||||||
|
const networks = await getNetworks()
|
||||||
|
const network = networks[config.network]
|
||||||
|
return `${network.web}/profile/${did}`
|
||||||
|
}
|
||||||
107
src/main.rs
Normal file
107
src/main.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
mod auth;
|
||||||
|
mod token;
|
||||||
|
mod post;
|
||||||
|
|
||||||
|
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 = "content")]
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Login { handle, password, server } => {
|
||||||
|
auth::login(&handle, &password, &server).await?;
|
||||||
|
}
|
||||||
|
Commands::Lexicon { file } => {
|
||||||
|
post::put_lexicon(&file).await?;
|
||||||
|
}
|
||||||
|
Commands::Post { file, collection, rkey } => {
|
||||||
|
post::put_record(&file, &collection, rkey.as_deref()).await?;
|
||||||
|
}
|
||||||
|
Commands::Get { collection, limit } => {
|
||||||
|
post::get_records(&collection, limit).await?;
|
||||||
|
}
|
||||||
|
Commands::Delete { collection, rkey } => {
|
||||||
|
post::delete_record(&collection, &rkey).await?;
|
||||||
|
}
|
||||||
|
Commands::Sync { output } => {
|
||||||
|
post::sync_to_local(&output).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
340
src/post.rs
Normal file
340
src/post.rs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
|
||||||
|
#[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)]
|
||||||
|
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<()> {
|
||||||
|
// Refresh token first
|
||||||
|
let session = auth::refresh_session().await?;
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
// Load record from file
|
||||||
|
let content = fs::read_to_string(file)
|
||||||
|
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||||
|
let record: Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
// Generate rkey if not provided
|
||||||
|
let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds);
|
||||||
|
|
||||||
|
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<()> {
|
||||||
|
// Refresh token first
|
||||||
|
let session = auth::refresh_session().await?;
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
// Load lexicon from file
|
||||||
|
let content = fs::read_to_string(file)
|
||||||
|
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||||
|
let lexicon: Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
// Get lexicon id for rkey
|
||||||
|
let lexicon_id = lexicon["id"]
|
||||||
|
.as_str()
|
||||||
|
.context("Lexicon file must have 'id' field")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds);
|
||||||
|
|
||||||
|
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 url = format!(
|
||||||
|
"https://{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}",
|
||||||
|
pds, 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 = format!("https://{}/xrpc/com.atproto.repo.deleteRecord", pds);
|
||||||
|
|
||||||
|
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<()> {
|
||||||
|
// Load config.json
|
||||||
|
let config_content = fs::read_to_string("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 and PDS
|
||||||
|
let resolve_url = format!(
|
||||||
|
"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?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);
|
||||||
|
|
||||||
|
// Create output directory
|
||||||
|
let did_dir = format!("{}/{}", output, did);
|
||||||
|
fs::create_dir_all(&did_dir)?;
|
||||||
|
|
||||||
|
// 1. Sync describeRepo
|
||||||
|
let describe_url = format!(
|
||||||
|
"{}/xrpc/com.atproto.repo.describeRepo?repo={}",
|
||||||
|
pds, 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!(
|
||||||
|
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.actor.profile&rkey=self",
|
||||||
|
pds, 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 (from config or default)
|
||||||
|
let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post");
|
||||||
|
let records_url = format!(
|
||||||
|
"{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100",
|
||||||
|
pds, 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)?;
|
||||||
|
|
||||||
|
for record in &list.records {
|
||||||
|
let rkey = record.uri.split('/').last().unwrap_or("unknown");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
println!("Synced {} records from {}", list.records.len(), collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Sync complete!");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
46
src/token.rs
Normal file
46
src/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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user