api/lexicons.zip
api/lexicons.zip
This is a binary file and will not be displayed.
-10
api/migrations/002_lexicons.sql
-10
api/migrations/002_lexicons.sql
···
1
-
-- Add lexicons table for storing AT Protocol lexicon schemas
2
-
CREATE TABLE IF NOT EXISTS "lexicons" (
3
-
"nsid" TEXT PRIMARY KEY NOT NULL,
4
-
"definitions" JSONB NOT NULL,
5
-
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
6
-
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
7
-
);
8
-
9
-
CREATE INDEX IF NOT EXISTS idx_lexicons_nsid ON "lexicons"("nsid");
10
-
CREATE INDEX IF NOT EXISTS idx_lexicons_definitions ON "lexicons" USING gin("definitions");
api/migrations/003_actors.sql
api/migrations/002_actors.sql
api/migrations/003_actors.sql
api/migrations/002_actors.sql
-1
api/scripts/generated_client.ts
-1
api/scripts/generated_client.ts
···
1
-
null
+6
-7
api/scripts/test_codegen.sh
+6
-7
api/scripts/test_codegen.sh
···
4
4
5
5
# Test with multiple lexicons
6
6
echo "📝 Generating TypeScript client with multiple lexicons..."
7
-
curl -s -X POST http://localhost:3000/xrpc/social.slices.codegen.generate \
7
+
curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.codegen \
8
8
-H "Content-Type: application/json" \
9
9
-d '{
10
-
"target": "typescript-deno",
11
-
"client_type": "records",
12
-
"lexicons": ["social.grain.gallery", "social.grain.comment"]
10
+
"target": "typescript",
11
+
"lexicons": ["social.slices.slice", "social.slices.lexicon"]
13
12
}' | jq -r '.generated_code' > generated_client.ts
14
13
15
14
if [ $? -eq 0 ] && [ -f generated_client.ts ]; then
···
17
16
echo "📊 Generated code stats:"
18
17
echo " Lines: $(wc -l < generated_client.ts)"
19
18
echo " Size: $(du -h generated_client.ts | cut -f1)"
20
-
19
+
21
20
echo ""
22
21
echo "🔍 Preview of generated interfaces:"
23
22
grep -A 5 "export interface.*Record {" generated_client.ts || echo "No record interfaces found"
24
-
23
+
25
24
echo ""
26
25
echo "🎯 Preview of auto-typing examples:"
27
26
grep -A 10 "// Usage examples:" generated_client.ts || echo "No usage examples found"
···
31
30
fi
32
31
33
32
echo ""
34
-
echo "🎉 Test complete! Check generated_client.ts to see the auto-typing functionality."
33
+
echo "🎉 Test complete! Check generated_client.ts to see the auto-typing functionality."
+19
api/scripts/test_sync.sh
+19
api/scripts/test_sync.sh
···
1
+
#!/bin/bash
2
+
3
+
echo "🔄 Testing Sync Endpoint..."
4
+
5
+
echo "🎯 Syncing specific collections with specific repos"
6
+
curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.sync \
7
+
-H "Content-Type: application/json" \
8
+
-d '{
9
+
"collections": [
10
+
"social.slice.slice",
11
+
"social.slice.lexicon"
12
+
],
13
+
"repos": [
14
+
"did:plc:bcgltzqazw5tb6k2g3ttenbj"
15
+
]
16
+
}' | jq '.'
17
+
18
+
echo ""
19
+
echo "✅ Sync test complete!"
+3
-25
api/src/codegen/typescript.rs
+3
-25
api/src/codegen/typescript.rs
···
1
-
use crate::models::Lexicon;
2
-
3
1
pub struct TypeScriptGenerator;
4
2
5
3
impl TypeScriptGenerator {
···
7
5
Self
8
6
}
9
7
10
-
pub fn generate_client(&self, lexicons: &[Lexicon]) -> Result<String, String> {
11
-
// Serialize lexicons to JSON for the Deno script
12
-
let lexicons_json = serde_json::to_string(lexicons)
13
-
.map_err(|e| format!("Failed to serialize lexicons: {}", e))?;
14
-
15
-
// Call the Deno script to generate TypeScript with proper comments
16
-
let output = std::process::Command::new("deno")
17
-
.arg("run")
18
-
.arg("--allow-all")
19
-
.arg("scripts/generate-typescript.ts")
20
-
.arg(&lexicons_json)
21
-
.output()
22
-
.map_err(|e| format!("Failed to execute deno script: {}", e))?;
23
-
24
-
if !output.status.success() {
25
-
let stderr = String::from_utf8_lossy(&output.stderr);
26
-
return Err(format!("Deno script failed: {}", stderr));
27
-
}
28
-
29
-
let generated_code = String::from_utf8(output.stdout)
30
-
.map_err(|e| format!("Failed to decode output: {}", e))?;
31
-
32
-
Ok(generated_code)
8
+
pub fn generate_client(&self, _lexicons_json: &str) -> Result<String, String> {
9
+
// TODO: Implement TypeScript generation - functionality moved to /frontend
10
+
Ok("// TypeScript generation placeholder - functionality moved to /frontend\n".to_string())
33
11
}
34
12
}
+19
-56
api/src/database.rs
+19
-56
api/src/database.rs
···
1
1
use sqlx::PgPool;
2
2
3
3
use crate::errors::DatabaseError;
4
-
use crate::models::{Actor, IndexedRecord, Lexicon, ListRecordsParams, Record};
4
+
use crate::models::{Actor, IndexedRecord, Record};
5
5
6
6
#[derive(Clone)]
7
7
pub struct Database {
···
85
85
Ok(indexed_record)
86
86
}
87
87
88
-
pub async fn list_records(&self, params: ListRecordsParams) -> Result<Vec<IndexedRecord>, DatabaseError> {
89
-
let limit = params.limit.unwrap_or(25).min(100);
88
+
pub async fn list_records(&self, collection: &str, author: Option<&str>, limit: Option<i32>) -> Result<Vec<IndexedRecord>, DatabaseError> {
89
+
let limit = limit.unwrap_or(25).min(100);
90
90
91
91
let records = sqlx::query_as::<_, Record>(
92
92
r#"SELECT "uri", "cid", "did", "collection", "json", "indexedAt"
···
96
96
ORDER BY "indexedAt" DESC
97
97
LIMIT $3"#,
98
98
)
99
-
.bind(¶ms.collection)
100
-
.bind(¶ms.author)
99
+
.bind(collection)
100
+
.bind(author)
101
101
.bind(limit)
102
102
.fetch_all(&self.pool)
103
103
.await?;
···
117
117
Ok(indexed_records)
118
118
}
119
119
120
-
pub async fn get_available_collections(&self) -> Result<Vec<(String, i64)>, DatabaseError> {
121
-
let collections = sqlx::query!(
122
-
r#"SELECT "collection", COUNT(*) as count
120
+
pub async fn get_lexicons_by_slice(&self, slice_uri: &str) -> Result<Vec<serde_json::Value>, DatabaseError> {
121
+
let records = sqlx::query_as::<_, Record>(
122
+
r#"SELECT "uri", "cid", "did", "collection", "json", "indexedAt"
123
123
FROM "record"
124
-
GROUP BY "collection"
125
-
ORDER BY count DESC, "collection" ASC"#
124
+
WHERE "collection" = 'social.slices.lexicon'
125
+
AND "json"->>'slice' = $1
126
+
ORDER BY "indexedAt" DESC"#,
126
127
)
128
+
.bind(slice_uri)
127
129
.fetch_all(&self.pool)
128
130
.await?;
129
131
130
-
Ok(collections
132
+
let lexicon_definitions: Vec<serde_json::Value> = records
131
133
.into_iter()
132
-
.map(|row| (row.collection, row.count.unwrap_or(0)))
133
-
.collect())
134
+
.filter_map(|record| record.json.get("definition").cloned())
135
+
.collect();
136
+
137
+
Ok(lexicon_definitions)
134
138
}
139
+
140
+
135
141
136
142
pub async fn get_total_record_count(&self) -> Result<i64, DatabaseError> {
137
143
let count = sqlx::query!("SELECT COUNT(*) as count FROM record")
···
141
147
Ok(count.count.unwrap_or(0))
142
148
}
143
149
144
-
pub async fn insert_lexicon(&self, lexicon: &Lexicon) -> Result<(), DatabaseError> {
145
-
sqlx::query!(
146
-
r#"INSERT INTO "lexicons" ("nsid", "definitions", "created_at", "updated_at")
147
-
VALUES ($1, $2, $3, $4)
148
-
ON CONFLICT ("nsid")
149
-
DO UPDATE SET
150
-
"definitions" = EXCLUDED."definitions",
151
-
"updated_at" = EXCLUDED."updated_at""#,
152
-
lexicon.nsid,
153
-
lexicon.definitions,
154
-
lexicon.created_at,
155
-
lexicon.updated_at
156
-
)
157
-
.execute(&self.pool)
158
-
.await?;
159
-
160
-
Ok(())
161
-
}
162
-
163
-
pub async fn get_lexicon(&self, nsid: &str) -> Result<Option<Lexicon>, DatabaseError> {
164
-
let lexicon = sqlx::query_as::<_, Lexicon>(
165
-
r#"SELECT "nsid", "definitions", "created_at", "updated_at"
166
-
FROM "lexicons"
167
-
WHERE "nsid" = $1"#,
168
-
)
169
-
.bind(nsid)
170
-
.fetch_optional(&self.pool)
171
-
.await?;
172
-
173
-
Ok(lexicon)
174
-
}
175
-
176
-
pub async fn get_all_lexicons(&self) -> Result<Vec<Lexicon>, DatabaseError> {
177
-
let lexicons = sqlx::query_as::<_, Lexicon>(
178
-
r#"SELECT "nsid", "definitions", "created_at", "updated_at"
179
-
FROM "lexicons"
180
-
ORDER BY "nsid""#,
181
-
)
182
-
.fetch_all(&self.pool)
183
-
.await?;
184
-
185
-
Ok(lexicons)
186
-
}
187
150
188
151
pub async fn update_record(&self, record: &Record) -> Result<(), DatabaseError> {
189
152
let result = sqlx::query!(
+1
-24
api/src/errors.rs
+1
-24
api/src/errors.rs
···
1
1
use thiserror::Error;
2
2
3
-
#[derive(Error, Debug)]
4
-
pub enum LexiconError {
5
-
#[error("error-slice-lexicon-1 Failed to parse multipart boundary: {0}")]
6
-
MultipartBoundary(String),
7
-
8
-
#[error("error-slice-lexicon-2 Failed to read request body: {0}")]
9
-
RequestBody(String),
10
-
11
-
#[error("error-slice-lexicon-3 Failed to parse zip archive: {0}")]
12
-
ZipArchive(String),
13
-
14
-
#[error("error-slice-lexicon-4 Failed to read file from archive: {0}")]
15
-
FileRead(String),
16
-
17
-
#[error("error-slice-lexicon-5 Failed to parse JSON lexicon: {0}")]
18
-
JsonParse(String),
19
-
}
20
3
21
4
#[derive(Error, Debug)]
22
5
pub enum DatabaseError {
23
6
#[error("error-slice-database-1 SQL query failed: {0}")]
24
7
SqlQuery(#[from] sqlx::Error),
25
8
26
-
#[error("error-slice-database-2 Transaction failed: {0}")]
27
-
Transaction(String),
28
-
29
-
#[error("error-slice-database-3 Record not found: {uri}")]
9
+
#[error("error-slice-database-2 Record not found: {uri}")]
30
10
RecordNotFound { uri: String },
31
11
}
32
12
···
64
44
65
45
#[error("error-slice-app-3 Server bind failed: {0}")]
66
46
ServerBind(#[from] std::io::Error),
67
-
68
-
#[error("error-slice-app-4 Environment variable error: {0}")]
69
-
Environment(String),
70
47
}
71
48
-103
api/src/handler_codegen.rs
-103
api/src/handler_codegen.rs
···
1
-
use axum::{
2
-
extract::State,
3
-
http::StatusCode,
4
-
response::{Html, IntoResponse},
5
-
};
6
-
use axum_extra::extract::Form;
7
-
use minijinja::{context, Environment};
8
-
use serde::Deserialize;
9
-
use crate::AppState;
10
-
use crate::codegen::TypeScriptGenerator;
11
-
12
-
#[derive(Deserialize)]
13
-
pub struct CodegenForm {
14
-
target: String,
15
-
client_type: String,
16
-
#[serde(default)]
17
-
lexicons: Vec<String>,
18
-
}
19
-
20
-
21
-
pub async fn generate_client(
22
-
State(state): State<AppState>,
23
-
Form(form): Form<CodegenForm>,
24
-
) -> Result<impl IntoResponse, StatusCode> {
25
-
let selected_lexicons = form.lexicons;
26
-
27
-
if selected_lexicons.is_empty() {
28
-
return Ok(Html(r#"
29
-
<div class="alert alert-error">
30
-
<h4>❌ No lexicons selected</h4>
31
-
<p>Please select at least one lexicon to generate client code.</p>
32
-
</div>
33
-
"#.to_string()));
34
-
}
35
-
36
-
// Fetch the selected lexicons from database
37
-
let mut lexicons = Vec::new();
38
-
for nsid in &selected_lexicons {
39
-
if let Ok(Some(lexicon)) = state.database.get_lexicon(nsid).await {
40
-
lexicons.push(lexicon);
41
-
}
42
-
}
43
-
44
-
let generated_code = match form.target.as_str() {
45
-
"typescript-deno" => match form.client_type.as_str() {
46
-
"records" => {
47
-
let generator = TypeScriptGenerator::new();
48
-
match generator.generate_client(&lexicons) {
49
-
Ok(code) => code,
50
-
Err(e) => return Ok(Html(format!(r#"
51
-
<div class="alert alert-error">
52
-
<h4>❌ TypeScript generation failed</h4>
53
-
<p>Error: {}</p>
54
-
</div>
55
-
"#, e))),
56
-
}
57
-
},
58
-
_ => return Ok(Html(r#"
59
-
<div class="alert alert-error">
60
-
<h4>❌ Unsupported client type</h4>
61
-
<p>Only "records" client type is currently supported.</p>
62
-
</div>
63
-
"#.to_string())),
64
-
},
65
-
_ => return Ok(Html(r#"
66
-
<div class="alert alert-error">
67
-
<h4>❌ Unsupported target</h4>
68
-
<p>Only "typescript-deno" is currently supported.</p>
69
-
</div>
70
-
"#.to_string())),
71
-
};
72
-
73
-
let mut env = Environment::new();
74
-
env.add_template("codegen_result.html", r#"
75
-
<div class="alert alert-success">
76
-
<h4>✅ Client code generated successfully!</h4>
77
-
<p><strong>Target:</strong> {{ target }}</p>
78
-
<p><strong>Client Type:</strong> {{ client_type }}</p>
79
-
<p><strong>Lexicons:</strong> {{ lexicons_count }}</p>
80
-
81
-
<div class="mt-4">
82
-
<div class="flex justify-between items-center mb-2">
83
-
<h5 class="font-medium text-gray-800">Generated Code</h5>
84
-
<button onclick="navigator.clipboard.writeText(document.getElementById('generated-code').textContent);"
85
-
class="bg-blue-500 text-white px-3 py-1 rounded text-sm">
86
-
Copy to Clipboard
87
-
</button>
88
-
</div>
89
-
<pre id="generated-code" class="bg-gray-100 p-4 rounded text-xs overflow-x-auto max-h-96 overflow-y-auto">{{ generated_code }}</pre>
90
-
</div>
91
-
</div>
92
-
"#).unwrap();
93
-
94
-
let tmpl = env.get_template("codegen_result.html").unwrap();
95
-
let rendered = tmpl.render(context! {
96
-
target => form.target,
97
-
client_type => form.client_type,
98
-
lexicons_count => lexicons.len(),
99
-
generated_code => generated_code
100
-
}).unwrap();
101
-
102
-
Ok(Html(rendered))
103
-
}
+2
-37
api/src/handler_dynamic_xrpc.rs
api/src/handler_xrpc_dynamic.rs
+2
-37
api/src/handler_dynamic_xrpc.rs
api/src/handler_xrpc_dynamic.rs
···
9
9
use atproto_identity::key::KeyData;
10
10
use atproto_oauth::jwk::WrappedJsonWebKey;
11
11
12
-
use crate::models::{ListRecordsParams, ListRecordsOutput, Record};
12
+
use crate::models::{ListRecordsOutput, Record};
13
13
use crate::AppState;
14
14
15
15
#[derive(Deserialize)]
···
24
24
pub uri: String,
25
25
}
26
26
27
-
#[derive(Deserialize)]
28
-
pub struct CreateRecordParams {
29
-
pub repo: String,
30
-
pub collection: String,
31
-
pub rkey: Option<String>,
32
-
pub record: serde_json::Value,
33
-
}
34
-
35
-
#[derive(Deserialize)]
36
-
pub struct UpdateRecordParams {
37
-
pub repo: String,
38
-
pub collection: String,
39
-
pub rkey: String,
40
-
pub record: serde_json::Value,
41
-
}
42
-
43
-
#[derive(Deserialize)]
44
-
pub struct DeleteRecordParams {
45
-
pub repo: String,
46
-
pub collection: String,
47
-
pub rkey: String,
48
-
}
49
-
50
-
#[derive(Serialize)]
51
-
pub struct CreateRecordOutput {
52
-
pub uri: String,
53
-
pub cid: String,
54
-
}
55
27
56
28
#[derive(Serialize, Deserialize, Debug)]
57
29
pub struct UserInfoResponse {
···
250
222
let dynamic_params: DynamicListParams = serde_json::from_value(params)
251
223
.map_err(|_| StatusCode::BAD_REQUEST)?;
252
224
253
-
let list_params = ListRecordsParams {
254
-
collection,
255
-
author: dynamic_params.author,
256
-
limit: dynamic_params.limit,
257
-
cursor: dynamic_params.cursor,
258
-
};
259
-
260
-
match state.database.list_records(list_params).await {
225
+
match state.database.list_records(&collection, dynamic_params.author.as_deref(), dynamic_params.limit).await {
261
226
Ok(records) => {
262
227
let output = ListRecordsOutput {
263
228
records,
-38
api/src/handler_lexicon.rs
-38
api/src/handler_lexicon.rs
···
1
-
use axum::{
2
-
extract::State,
3
-
http::StatusCode,
4
-
response::{Html, IntoResponse},
5
-
};
6
-
use minijinja::{context, Environment};
7
-
8
-
use crate::AppState;
9
-
10
-
pub async fn lexicon_page(
11
-
State(state): State<AppState>,
12
-
) -> Result<impl IntoResponse, StatusCode> {
13
-
let lexicons = state.database.get_all_lexicons().await.unwrap_or_default();
14
-
15
-
// Transform lexicons to include pretty-printed JSON
16
-
let lexicons_with_pretty_json: Vec<serde_json::Value> = lexicons.into_iter().map(|lexicon| {
17
-
let pretty_definitions = serde_json::to_string_pretty(&lexicon.definitions).unwrap_or_else(|_| "{}".to_string());
18
-
serde_json::json!({
19
-
"nsid": lexicon.nsid,
20
-
"definitions": lexicon.definitions,
21
-
"pretty_definitions": pretty_definitions,
22
-
"created_at": lexicon.created_at,
23
-
"updated_at": lexicon.updated_at
24
-
})
25
-
}).collect();
26
-
27
-
let mut env = Environment::new();
28
-
env.add_template("base.html", include_str!("../templates/base.html")).unwrap();
29
-
env.add_template("lexicon.html", include_str!("../templates/lexicon.html")).unwrap();
30
-
31
-
let tmpl = env.get_template("lexicon.html").unwrap();
32
-
let rendered = tmpl.render(context! {
33
-
title => "Lexicon Definitions",
34
-
lexicons => lexicons_with_pretty_json
35
-
}).unwrap();
36
-
37
-
Ok(Html(rendered))
38
-
}
+36
api/src/handler_sync.rs
+36
api/src/handler_sync.rs
···
1
+
use axum::{
2
+
extract::State,
3
+
http::StatusCode,
4
+
response::Json,
5
+
};
6
+
use crate::models::{BulkSyncOutput, BulkSyncParams};
7
+
use crate::AppState;
8
+
9
+
pub async fn sync(
10
+
State(state): State<AppState>,
11
+
axum::extract::Json(params): axum::extract::Json<BulkSyncParams>,
12
+
) -> Result<Json<BulkSyncOutput>, StatusCode> {
13
+
match state
14
+
.sync_service
15
+
.backfill_collections(¶ms.collections, params.repos.as_deref())
16
+
.await
17
+
{
18
+
Ok(_) => {
19
+
let total_records = state.database.get_total_record_count().await.unwrap_or(0);
20
+
Ok(Json(BulkSyncOutput {
21
+
success: true,
22
+
total_records,
23
+
collections_synced: params.collections,
24
+
repos_processed: params.repos.map(|r| r.len() as i64).unwrap_or(0),
25
+
message: "Sync completed successfully".to_string(),
26
+
}))
27
+
}
28
+
Err(e) => Ok(Json(BulkSyncOutput {
29
+
success: false,
30
+
total_records: 0,
31
+
collections_synced: vec![],
32
+
repos_processed: 0,
33
+
message: format!("Sync failed: {}", e),
34
+
})),
35
+
}
36
+
}
-250
api/src/handler_upload_lexicon.rs
-250
api/src/handler_upload_lexicon.rs
···
1
-
use axum::{
2
-
extract::{Request, State},
3
-
http::StatusCode,
4
-
response::{Html, IntoResponse},
5
-
};
6
-
use axum::body::to_bytes;
7
-
use multer::Multipart;
8
-
use futures_util::stream::once;
9
-
use crate::errors::LexiconError;
10
-
use crate::models::Lexicon;
11
-
use crate::AppState;
12
-
use minijinja::{context, Environment};
13
-
use serde::Deserialize;
14
-
use std::collections::HashSet;
15
-
use std::io::Read;
16
-
use tracing::{error, warn};
17
-
use zip::ZipArchive;
18
-
use chrono::Utc;
19
-
20
-
#[derive(Deserialize)]
21
-
struct LexiconFile {
22
-
id: String,
23
-
defs: serde_json::Map<String, serde_json::Value>,
24
-
}
25
-
26
-
pub async fn upload_lexicons(
27
-
State(state): State<AppState>,
28
-
request: Request,
29
-
) -> Result<impl IntoResponse, StatusCode> {
30
-
31
-
let boundary = request
32
-
.headers()
33
-
.get("content-type")
34
-
.and_then(|ct| ct.to_str().ok())
35
-
.and_then(|ct| multer::parse_boundary(ct).ok())
36
-
.ok_or_else(|| {
37
-
let err = LexiconError::MultipartBoundary("Missing or invalid content-type header".to_string());
38
-
error!("{}", err);
39
-
StatusCode::BAD_REQUEST
40
-
})?;
41
-
42
-
let body = request.into_body();
43
-
let body_bytes = to_bytes(body, usize::MAX).await.map_err(|e| {
44
-
let err = LexiconError::RequestBody(e.to_string());
45
-
error!("{}", err);
46
-
StatusCode::BAD_REQUEST
47
-
})?;
48
-
49
-
50
-
let body_stream = once(async move { Ok::<_, multer::Error>(body_bytes) });
51
-
let mut multipart = Multipart::new(body_stream, boundary);
52
-
let mut collections = HashSet::new();
53
-
let mut file_count = 0;
54
-
let mut record_count = 0;
55
-
56
-
while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
57
-
if field.name() == Some("lexicon_file") {
58
-
let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
59
-
60
-
// Parse zip file
61
-
let cursor = std::io::Cursor::new(data);
62
-
let mut archive = ZipArchive::new(cursor).map_err(|e| {
63
-
let err = LexiconError::ZipArchive(e.to_string());
64
-
error!("{}", err);
65
-
StatusCode::BAD_REQUEST
66
-
})?;
67
-
68
-
69
-
for i in 0..archive.len() {
70
-
let mut file = archive.by_index(i).map_err(|e| {
71
-
let err = LexiconError::FileRead(format!("Failed to access file at index {}: {}", i, e));
72
-
error!("{}", err);
73
-
StatusCode::INTERNAL_SERVER_ERROR
74
-
})?;
75
-
76
-
// Only process JSON files, skip macOS metadata files
77
-
if file.name().ends_with(".json") &&
78
-
!file.name().contains("__MACOSX") &&
79
-
!file.name().starts_with("._") {
80
-
81
-
let mut contents = String::new();
82
-
if let Err(e) = file.read_to_string(&mut contents) {
83
-
let err = LexiconError::FileRead(format!("Failed to read {}: {}", file.name(), e));
84
-
warn!("{}", err);
85
-
continue; // Skip this file and continue processing others
86
-
}
87
-
88
-
// Try to parse as lexicon
89
-
match serde_json::from_str::<LexiconFile>(&contents) {
90
-
Ok(lexicon_file) => {
91
-
file_count += 1;
92
-
93
-
// Look for record definitions first
94
-
let mut has_record_def = false;
95
-
for (_def_name, def_value) in &lexicon_file.defs {
96
-
if let Some(def_obj) = def_value.as_object() {
97
-
if let Some(type_val) = def_obj.get("type") {
98
-
if type_val == "record" {
99
-
// This is a record definition - for AT Protocol listRecords, we only use the NSID
100
-
// Fragments (#definition) are for Lexicon references, not collection names
101
-
collections.insert(lexicon_file.id.clone());
102
-
record_count += 1;
103
-
has_record_def = true;
104
-
}
105
-
}
106
-
}
107
-
}
108
-
109
-
// Only store lexicon in database if it has record definitions (will be synced)
110
-
if has_record_def {
111
-
let now = Utc::now();
112
-
let lexicon = Lexicon {
113
-
nsid: lexicon_file.id.clone(),
114
-
definitions: serde_json::Value::Object(lexicon_file.defs.clone()),
115
-
created_at: now,
116
-
updated_at: now,
117
-
};
118
-
119
-
if let Err(e) = state.database.insert_lexicon(&lexicon).await {
120
-
warn!("Failed to store lexicon {}: {}", lexicon_file.id, e);
121
-
}
122
-
}
123
-
}
124
-
Err(e) => {
125
-
let err = LexiconError::JsonParse(format!("Failed to parse {}: {}", file.name(), e));
126
-
warn!("{}", err);
127
-
}
128
-
}
129
-
}
130
-
}
131
-
}
132
-
}
133
-
134
-
let collections_list: Vec<String> = collections.into_iter().collect();
135
-
let collections_str = collections_list.join(", ");
136
-
137
-
// Group collections by domain - create a vector of objects for template
138
-
let mut domain_groups: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
139
-
for collection in &collections_list {
140
-
let domain = if let Some(dot_index) = collection.find('.') {
141
-
collection[..dot_index].to_string()
142
-
} else {
143
-
"other".to_string()
144
-
};
145
-
domain_groups.entry(domain).or_insert_with(Vec::new).push(collection.clone());
146
-
}
147
-
148
-
// Convert to a format that minijinja can handle
149
-
let grouped_collections: Vec<serde_json::Value> = domain_groups
150
-
.into_iter()
151
-
.map(|(domain, collections)| {
152
-
serde_json::json!({
153
-
"domain": domain,
154
-
"collections": collections
155
-
})
156
-
})
157
-
.collect();
158
-
159
-
160
-
let mut env = Environment::new();
161
-
env.add_template("upload_result.html", r#"
162
-
<div class="alert alert-success">
163
-
<h4>✅ Lexicon parsing completed!</h4>
164
-
<p><strong>Files processed:</strong> {{ file_count }}</p>
165
-
<p><strong>Record definitions found:</strong> {{ record_count }}</p>
166
-
167
-
<div class="mt-4">
168
-
<h5 class="font-medium text-gray-800 mb-2">Select Collections to Sync:</h5>
169
-
<div class="space-y-3">
170
-
{% for group in grouped_collections %}
171
-
<div class="border border-gray-200 rounded-lg p-3">
172
-
<div class="flex items-center mb-2">
173
-
<input type="checkbox"
174
-
id="domain-{{ group.domain }}"
175
-
class="domain-checkbox mr-2"
176
-
_="on change toggle .checked on .collection-{{ group.domain }} then call updateCollections()">
177
-
<label for="domain-{{ group.domain }}" class="font-medium text-gray-700">{{ group.domain }}.*</label>
178
-
</div>
179
-
<div class="ml-6 space-y-1">
180
-
{% for collection in group.collections %}
181
-
<div class="flex items-center">
182
-
<input type="checkbox"
183
-
id="collection-{{ collection }}"
184
-
class="collection-checkbox collection-{{ group.domain }} mr-2"
185
-
value="{{ collection }}"
186
-
_="on change call updateCollections()">
187
-
<label for="collection-{{ collection }}" class="text-sm text-gray-600 font-mono">{{ collection }}</label>
188
-
</div>
189
-
{% endfor %}
190
-
</div>
191
-
</div>
192
-
{% endfor %}
193
-
</div>
194
-
195
-
<div class="mt-4">
196
-
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm mr-2"
197
-
_="on click add .checked to .collection-checkbox then call updateCollections()">
198
-
Select All
199
-
</button>
200
-
<button class="bg-gray-500 text-white px-3 py-1 rounded text-sm"
201
-
_="on click remove .checked from .collection-checkbox then call updateCollections()">
202
-
Select None
203
-
</button>
204
-
</div>
205
-
</div>
206
-
207
-
<script>
208
-
function updateCollections() {
209
-
const selectedCollections = Array.from(document.querySelectorAll('.collection-checkbox:checked'))
210
-
.map(cb => cb.value);
211
-
212
-
document.getElementById('collections').value = selectedCollections.join(', ');
213
-
214
-
// Update domain checkboxes
215
-
document.querySelectorAll('.domain-checkbox').forEach(domainCb => {
216
-
const domain = domainCb.id.replace('domain-', '');
217
-
const domainCollections = document.querySelectorAll('.collection-' + domain);
218
-
const checkedDomainCollections = document.querySelectorAll('.collection-' + domain + ':checked');
219
-
220
-
if (checkedDomainCollections.length === 0) {
221
-
domainCb.checked = false;
222
-
domainCb.indeterminate = false;
223
-
} else if (checkedDomainCollections.length === domainCollections.length) {
224
-
domainCb.checked = true;
225
-
domainCb.indeterminate = false;
226
-
} else {
227
-
domainCb.checked = false;
228
-
domainCb.indeterminate = true;
229
-
}
230
-
});
231
-
}
232
-
233
-
// Auto-populate with all collections initially and check all boxes
234
-
document.getElementById('collections').value = '{{ collections_str }}';
235
-
document.querySelectorAll('.collection-checkbox').forEach(cb => cb.checked = true);
236
-
document.querySelectorAll('.domain-checkbox').forEach(cb => cb.checked = true);
237
-
</script>
238
-
</div>
239
-
"#).unwrap();
240
-
241
-
let tmpl = env.get_template("upload_result.html").unwrap();
242
-
let rendered = tmpl.render(context! {
243
-
file_count => file_count,
244
-
record_count => record_count,
245
-
collections_str => collections_str,
246
-
grouped_collections => grouped_collections
247
-
}).unwrap();
248
-
249
-
Ok(Html(rendered))
250
-
}
+25
-46
api/src/handler_xrpc_codegen.rs
+25
-46
api/src/handler_xrpc_codegen.rs
···
9
9
10
10
#[derive(Deserialize)]
11
11
pub struct CodegenXrpcRequest {
12
+
#[allow(dead_code)]
12
13
target: String,
13
-
client_type: String,
14
-
lexicons: Vec<String>,
14
+
slice: String, // at-uri of the slice to filter lexicons by
15
15
}
16
16
17
17
#[derive(Serialize)]
···
25
25
State(state): State<AppState>,
26
26
Json(request): Json<CodegenXrpcRequest>,
27
27
) -> Result<ResponseJson<CodegenXrpcResponse>, StatusCode> {
28
-
if request.lexicons.is_empty() {
29
-
return Ok(ResponseJson(CodegenXrpcResponse {
28
+
// Query database for lexicon definitions by slice
29
+
let lexicon_definitions = match state.database.get_lexicons_by_slice(&request.slice).await {
30
+
Ok(definitions) => definitions,
31
+
Err(_) => return Ok(ResponseJson(CodegenXrpcResponse {
30
32
success: false,
31
33
generated_code: None,
32
-
error: Some("No lexicons specified".to_string()),
33
-
}));
34
-
}
35
-
36
-
// Fetch the selected lexicons from database
37
-
let mut lexicons = Vec::new();
38
-
for nsid in &request.lexicons {
39
-
if let Ok(Some(lexicon)) = state.database.get_lexicon(nsid).await {
40
-
lexicons.push(lexicon);
41
-
}
42
-
}
34
+
error: Some("Failed to query lexicon records".to_string()),
35
+
}))
36
+
};
43
37
44
-
if lexicons.is_empty() {
38
+
if lexicon_definitions.is_empty() {
45
39
return Ok(ResponseJson(CodegenXrpcResponse {
46
40
success: false,
47
41
generated_code: None,
48
-
error: Some("No valid lexicons found".to_string()),
42
+
error: Some(format!("No lexicons found for slice: {}", request.slice)),
49
43
}));
50
44
}
51
45
52
-
let generated_code = match request.target.as_str() {
53
-
"typescript-deno" => match request.client_type.as_str() {
54
-
"records" => {
55
-
let generator = TypeScriptGenerator::new();
56
-
match generator.generate_client(&lexicons) {
57
-
Ok(code) => code,
58
-
Err(e) => return Ok(ResponseJson(CodegenXrpcResponse {
59
-
success: false,
60
-
generated_code: None,
61
-
error: Some(format!("TypeScript generation failed: {}", e)),
62
-
})),
63
-
}
64
-
},
65
-
_ => return Ok(ResponseJson(CodegenXrpcResponse {
66
-
success: false,
67
-
generated_code: None,
68
-
error: Some("Unsupported client type".to_string()),
69
-
})),
70
-
},
71
-
_ => return Ok(ResponseJson(CodegenXrpcResponse {
46
+
// Pass lexicon definitions to TypeScript generator
47
+
let generator = TypeScriptGenerator::new();
48
+
let lexicons_json = serde_json::to_string(&lexicon_definitions).unwrap_or_default();
49
+
50
+
match generator.generate_client(&lexicons_json) {
51
+
Ok(code) => Ok(ResponseJson(CodegenXrpcResponse {
52
+
success: true,
53
+
generated_code: Some(code),
54
+
error: None,
55
+
})),
56
+
Err(e) => Ok(ResponseJson(CodegenXrpcResponse {
72
57
success: false,
73
58
generated_code: None,
74
-
error: Some("Unsupported target".to_string()),
75
-
})),
76
-
};
77
-
78
-
Ok(ResponseJson(CodegenXrpcResponse {
79
-
success: true,
80
-
generated_code: Some(generated_code),
81
-
error: None,
82
-
}))
59
+
error: Some(e),
60
+
}))
61
+
}
83
62
}
+7
-107
api/src/main.rs
+7
-107
api/src/main.rs
···
1
1
mod codegen;
2
2
mod database;
3
3
mod errors;
4
-
mod handler_codegen;
5
-
mod handler_dynamic_xrpc;
6
-
mod handler_lexicon;
7
-
mod handler_upload_lexicon;
4
+
mod handler_sync;
8
5
mod handler_xrpc_codegen;
6
+
mod handler_xrpc_dynamic;
9
7
mod models;
10
8
mod sync;
11
9
mod utils;
12
-
mod web;
13
10
14
11
use axum::{
15
12
Router,
16
-
extract::{Query, State},
17
-
http::StatusCode,
18
-
response::Json,
19
13
routing::{get, post},
20
14
};
21
15
use sqlx::PgPool;
···
26
20
27
21
use crate::database::Database;
28
22
use crate::errors::AppError;
29
-
use crate::models::{
30
-
BulkSyncOutput, BulkSyncParams, ListRecordsOutput, ListRecordsParams, SmartSyncParams,
31
-
};
32
23
use crate::sync::SyncService;
33
-
use crate::web::WebService;
34
24
35
25
#[derive(Clone)]
36
26
pub struct Config {
···
41
31
pub struct AppState {
42
32
database: Database,
43
33
sync_service: SyncService,
44
-
#[allow(dead_code)]
45
-
web_service: WebService,
46
34
config: Config,
47
35
}
48
36
···
67
55
68
56
let database = Database::new(pool);
69
57
let sync_service = SyncService::new(database.clone());
70
-
let web_service = WebService::new();
71
58
72
59
let auth_base_url = env::var("AUTH_BASE_URL")
73
60
.unwrap_or_else(|_| "https://auth.grainsocial.network".to_string());
74
61
75
-
let config = Config {
76
-
auth_base_url,
77
-
};
62
+
let config = Config { auth_base_url };
78
63
79
64
let state = AppState {
80
65
database: database.clone(),
81
66
sync_service,
82
-
web_service,
83
67
config,
84
68
};
85
69
86
70
// Build application with routes
87
71
let app = Router::new()
88
72
// XRPC endpoints
89
-
.route("/xrpc/social.slices.records.list", get(list_records))
90
-
.route("/xrpc/social.slices.collections.bulkSync", post(bulk_sync))
91
-
.route("/xrpc/social.slices.repos.smartSync", post(smart_sync))
73
+
.route("/xrpc/social.slices.slice.sync", post(handler_sync::sync))
92
74
.route(
93
-
"/xrpc/social.slices.codegen.generate",
75
+
"/xrpc/social.slices.slice.codegen",
94
76
post(handler_xrpc_codegen::generate_client_xrpc),
95
77
)
96
78
// Dynamic collection-specific XRPC endpoints
97
79
.route(
98
80
"/xrpc/*method",
99
-
get(handler_dynamic_xrpc::dynamic_xrpc_handler),
81
+
get(handler_xrpc_dynamic::dynamic_xrpc_handler),
100
82
)
101
83
.route(
102
84
"/xrpc/*method",
103
-
post(handler_dynamic_xrpc::dynamic_xrpc_post_handler),
104
-
)
105
-
// Web interface
106
-
.route("/", get(web::index))
107
-
.route("/records", get(web::records_page))
108
-
.route("/sync", get(web::sync_page))
109
-
.route("/sync", post(web::bulk_sync_action))
110
-
.route("/codegen", get(web::codegen_page))
111
-
.route("/codegen/generate", post(handler_codegen::generate_client))
112
-
.route("/lexicon", get(handler_lexicon::lexicon_page))
113
-
.route(
114
-
"/upload-lexicons",
115
-
post(handler_upload_lexicon::upload_lexicons),
85
+
post(handler_xrpc_dynamic::dynamic_xrpc_post_handler),
116
86
)
117
87
.layer(TraceLayer::new_for_http())
118
88
.layer(CorsLayer::permissive())
···
125
95
Ok(())
126
96
}
127
97
128
-
async fn list_records(
129
-
State(state): State<AppState>,
130
-
Query(params): Query<ListRecordsParams>,
131
-
) -> Result<Json<ListRecordsOutput>, StatusCode> {
132
-
match state.database.list_records(params).await {
133
-
Ok(records) => Ok(Json(ListRecordsOutput {
134
-
records,
135
-
cursor: None, // TODO: implement cursor pagination
136
-
})),
137
-
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
138
-
}
139
-
}
140
98
141
-
async fn bulk_sync(
142
-
State(state): State<AppState>,
143
-
axum::extract::Json(params): axum::extract::Json<BulkSyncParams>,
144
-
) -> Result<Json<BulkSyncOutput>, StatusCode> {
145
-
match state
146
-
.sync_service
147
-
.backfill_collections(¶ms.collections, params.repos.as_deref())
148
-
.await
149
-
{
150
-
Ok(_) => {
151
-
let total_records = state.database.get_total_record_count().await.unwrap_or(0);
152
-
Ok(Json(BulkSyncOutput {
153
-
success: true,
154
-
total_records,
155
-
collections_synced: params.collections,
156
-
repos_processed: params.repos.map(|r| r.len() as i64).unwrap_or(0),
157
-
message: "Bulk sync completed successfully".to_string(),
158
-
}))
159
-
}
160
-
Err(e) => Ok(Json(BulkSyncOutput {
161
-
success: false,
162
-
total_records: 0,
163
-
collections_synced: vec![],
164
-
repos_processed: 0,
165
-
message: format!("Bulk sync failed: {}", e),
166
-
})),
167
-
}
168
-
}
169
-
170
-
async fn smart_sync(
171
-
State(state): State<AppState>,
172
-
axum::extract::Json(params): axum::extract::Json<SmartSyncParams>,
173
-
) -> Result<Json<BulkSyncOutput>, StatusCode> {
174
-
let collections = params.collections.as_deref();
175
-
176
-
match state.sync_service.sync_repo(¶ms.did, collections).await {
177
-
Ok(records_count) => {
178
-
let total_records = state.database.get_total_record_count().await.unwrap_or(0);
179
-
Ok(Json(BulkSyncOutput {
180
-
success: true,
181
-
total_records,
182
-
collections_synced: params.collections.unwrap_or_default(),
183
-
repos_processed: 1,
184
-
message: format!(
185
-
"Smart sync completed for {}: {} records",
186
-
params.did, records_count
187
-
),
188
-
}))
189
-
}
190
-
Err(e) => Ok(Json(BulkSyncOutput {
191
-
success: false,
192
-
total_records: 0,
193
-
collections_synced: vec![],
194
-
repos_processed: 0,
195
-
message: format!("Smart sync failed for {}: {}", params.did, e),
196
-
})),
197
-
}
198
-
}
+5
-44
api/src/models.rs
+5
-44
api/src/models.rs
···
15
15
}
16
16
17
17
#[derive(Debug, Serialize, Deserialize)]
18
-
pub struct CreateRecordInput {
19
-
pub collection: String,
20
-
pub repo: String,
21
-
pub rkey: Option<String>,
22
-
pub record: Value,
23
-
}
24
-
25
-
#[derive(Debug, Serialize, Deserialize)]
26
-
pub struct CreateRecordOutput {
18
+
pub struct IndexedRecord {
27
19
pub uri: String,
28
20
pub cid: String,
29
-
}
30
-
31
-
#[derive(Debug, Serialize, Deserialize)]
32
-
pub struct ListRecordsParams {
21
+
pub did: String,
33
22
pub collection: String,
34
-
pub author: Option<String>,
35
-
pub limit: Option<i32>,
36
-
pub cursor: Option<String>,
23
+
pub value: Value,
24
+
#[serde(rename = "indexedAt")]
25
+
pub indexed_at: String,
37
26
}
38
27
39
28
#[derive(Debug, Serialize, Deserialize)]
···
43
32
}
44
33
45
34
#[derive(Debug, Serialize, Deserialize)]
46
-
pub struct IndexedRecord {
47
-
pub uri: String,
48
-
pub cid: String,
49
-
pub did: String,
50
-
pub collection: String,
51
-
pub value: Value,
52
-
#[serde(rename = "indexedAt")]
53
-
pub indexed_at: String,
54
-
}
55
-
56
-
57
-
#[derive(Debug, Serialize, Deserialize)]
58
35
pub struct BulkSyncParams {
59
36
pub collections: Vec<String>,
60
37
pub repos: Option<Vec<String>>,
···
68
45
pub collections_synced: Vec<String>,
69
46
pub repos_processed: i64,
70
47
pub message: String,
71
-
}
72
-
73
-
74
-
#[derive(Debug, Serialize, Deserialize)]
75
-
pub struct SmartSyncParams {
76
-
pub did: String,
77
-
pub collections: Option<Vec<String>>,
78
-
pub force_full_sync: Option<bool>,
79
-
}
80
-
81
-
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
82
-
pub struct Lexicon {
83
-
pub nsid: String,
84
-
pub definitions: serde_json::Value,
85
-
pub created_at: DateTime<Utc>,
86
-
pub updated_at: DateTime<Utc>,
87
48
}
88
49
89
50
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
-39
api/src/sync.rs
-39
api/src/sync.rs
···
68
68
}
69
69
}
70
70
71
-
// Sync using listRecords
72
-
pub async fn sync_repo(&self, did: &str, collections: Option<&[String]>) -> Result<i64, SyncError> {
73
-
info!("🔄 Starting sync for DID: {}", did);
74
-
75
-
let total_records = self.listrecords_sync(did, collections).await?;
76
-
77
-
info!("✅ Sync completed for {}: {} records", did, total_records);
78
-
Ok(total_records)
79
-
}
80
-
81
-
82
-
// Sync using listRecords
83
-
async fn listrecords_sync(&self, did: &str, collections: Option<&[String]>) -> Result<i64, SyncError> {
84
-
let collections_to_sync = match collections {
85
-
Some(cols) => cols,
86
-
None => return Ok(0), // No collections specified = no records
87
-
};
88
-
89
-
// Get ATP data for this single repo
90
-
let atp_map = self.get_atp_map_for_repos(&[did.to_string()]).await?;
91
-
92
-
let mut total_records = 0;
93
-
for collection in collections_to_sync {
94
-
match self.fetch_records_for_repo_collection_with_atp_map(did, collection, &atp_map).await {
95
-
Ok(records) => {
96
-
if !records.is_empty() {
97
-
info!("📋 Fallback sync: {} records for {}/{}", records.len(), did, collection);
98
-
self.database.batch_insert_records(&records).await?;
99
-
total_records += records.len() as i64;
100
-
}
101
-
}
102
-
Err(e) => {
103
-
error!("Failed fallback sync for {}/{}: {}", did, collection, e);
104
-
}
105
-
}
106
-
}
107
-
108
-
Ok(total_records)
109
-
}
110
71
111
72
112
73
pub async fn backfill_collections(&self, collections: &[String], repos: Option<&[String]>) -> Result<(), SyncError> {
-204
api/src/web.rs
-204
api/src/web.rs
···
1
-
use axum::{
2
-
extract::{Query, State},
3
-
http::StatusCode,
4
-
response::{Html, IntoResponse},
5
-
Form,
6
-
};
7
-
use minijinja::{context, Environment};
8
-
use serde::Deserialize;
9
-
use std::collections::HashMap;
10
-
11
-
use crate::models::ListRecordsParams;
12
-
use crate::AppState;
13
-
14
-
#[derive(Clone)]
15
-
pub struct WebService {
16
-
#[allow(dead_code)]
17
-
env: Environment<'static>,
18
-
}
19
-
20
-
impl WebService {
21
-
pub fn new() -> Self {
22
-
let mut env = Environment::new();
23
-
24
-
// Add base template
25
-
env.add_template("base.html", include_str!("../templates/base.html")).unwrap();
26
-
env.add_template("index.html", include_str!("../templates/index.html")).unwrap();
27
-
env.add_template("records.html", include_str!("../templates/records.html")).unwrap();
28
-
env.add_template("sync.html", include_str!("../templates/sync.html")).unwrap();
29
-
30
-
Self { env }
31
-
}
32
-
}
33
-
34
-
#[derive(Deserialize)]
35
-
pub struct BulkSyncForm {
36
-
collections: String,
37
-
repos: Option<String>,
38
-
}
39
-
40
-
41
-
pub async fn index(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> {
42
-
let collections = state.database.get_available_collections().await.unwrap_or_default();
43
-
let total_records = state.database.get_total_record_count().await.unwrap_or(0);
44
-
45
-
let mut env = Environment::new();
46
-
env.add_template("base.html", include_str!("../templates/base.html")).unwrap();
47
-
env.add_template("index.html", include_str!("../templates/index.html")).unwrap();
48
-
49
-
let tmpl = env.get_template("index.html").unwrap();
50
-
let rendered = tmpl.render(context! {
51
-
title => "AT Protocol Indexer",
52
-
collections => collections,
53
-
total_records => total_records
54
-
}).unwrap();
55
-
56
-
Ok(Html(rendered))
57
-
}
58
-
59
-
pub async fn records_page(
60
-
State(state): State<AppState>,
61
-
Query(params): Query<HashMap<String, String>>,
62
-
) -> Result<impl IntoResponse, StatusCode> {
63
-
let collection = params.get("collection").cloned().unwrap_or_default();
64
-
let author = params.get("author").cloned();
65
-
66
-
// Get available collections for the dropdown
67
-
let available_collections = state.database.get_available_collections().await.unwrap_or_default();
68
-
69
-
let records = if !collection.is_empty() {
70
-
let list_params = ListRecordsParams {
71
-
collection: collection.clone(),
72
-
author,
73
-
limit: Some(50),
74
-
cursor: None,
75
-
};
76
-
let raw_records = state.database.list_records(list_params).await.unwrap_or_default();
77
-
78
-
// Transform records to include pretty-printed JSON
79
-
raw_records.into_iter().map(|record| {
80
-
let pretty_json = serde_json::to_string_pretty(&record.value).unwrap_or_else(|_| record.value.to_string());
81
-
serde_json::json!({
82
-
"uri": record.uri,
83
-
"cid": record.cid,
84
-
"did": record.did,
85
-
"collection": record.collection,
86
-
"value": record.value,
87
-
"pretty_value": pretty_json,
88
-
"indexed_at": record.indexed_at
89
-
})
90
-
}).collect()
91
-
} else {
92
-
Vec::new()
93
-
};
94
-
95
-
let mut env = Environment::new();
96
-
env.add_template("base.html", include_str!("../templates/base.html")).unwrap();
97
-
env.add_template("records.html", include_str!("../templates/records.html")).unwrap();
98
-
99
-
let tmpl = env.get_template("records.html").unwrap();
100
-
let rendered = tmpl.render(context! {
101
-
title => "Records",
102
-
records => records,
103
-
collection => collection,
104
-
available_collections => available_collections
105
-
}).unwrap();
106
-
107
-
Ok(Html(rendered))
108
-
}
109
-
110
-
pub async fn codegen_page(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> {
111
-
// Get stored lexicons for the UI
112
-
let lexicons = match state.database.get_all_lexicons().await {
113
-
Ok(lexicons) => lexicons,
114
-
Err(_) => Vec::new(),
115
-
};
116
-
117
-
let mut env = Environment::new();
118
-
env.add_template("base.html", include_str!("../templates/base.html")).unwrap();
119
-
env.add_template("codegen.html", include_str!("../templates/codegen.html")).unwrap();
120
-
121
-
let tmpl = env.get_template("codegen.html").unwrap();
122
-
let rendered = tmpl.render(context! {
123
-
title => "Client Code Generation",
124
-
lexicons => lexicons
125
-
}).unwrap();
126
-
127
-
Ok(Html(rendered))
128
-
}
129
-
130
-
pub async fn sync_page() -> impl IntoResponse {
131
-
let mut env = Environment::new();
132
-
env.add_template("base.html", include_str!("../templates/base.html")).unwrap();
133
-
env.add_template("sync.html", include_str!("../templates/sync.html")).unwrap();
134
-
135
-
let tmpl = env.get_template("sync.html").unwrap();
136
-
let rendered = tmpl.render(context! {
137
-
title => "Sync Records"
138
-
}).unwrap();
139
-
140
-
Html(rendered)
141
-
}
142
-
143
-
pub async fn bulk_sync_action(
144
-
State(state): State<AppState>,
145
-
Form(form): Form<BulkSyncForm>,
146
-
) -> Result<impl IntoResponse, StatusCode> {
147
-
// Parse collections from comma-separated string
148
-
let collections: Vec<String> = form.collections
149
-
.split(',')
150
-
.map(|s| s.trim().to_string())
151
-
.filter(|s| !s.is_empty())
152
-
.collect();
153
-
154
-
// Parse repos from newline-separated string if provided
155
-
let repos = form.repos
156
-
.filter(|s| !s.trim().is_empty())
157
-
.map(|s| s.lines()
158
-
.map(|line| line.trim().to_string())
159
-
.filter(|line| !line.is_empty())
160
-
.collect::<Vec<String>>());
161
-
162
-
if collections.is_empty() {
163
-
return Ok(Html(r#"
164
-
<div class="alert alert-error">
165
-
<h4>❌ No collections specified</h4>
166
-
<p>Please specify at least one collection to sync.</p>
167
-
</div>
168
-
"#.to_string()));
169
-
}
170
-
171
-
match state.sync_service.backfill_collections(&collections, repos.as_deref()).await {
172
-
Ok(_) => {
173
-
let total_records = state.database.get_total_record_count().await.unwrap_or(0);
174
-
175
-
let mut env = Environment::new();
176
-
env.add_template("sync_result.html", r#"
177
-
<div class="alert alert-success">
178
-
<h4>✅ Bulk sync completed successfully!</h4>
179
-
<p><strong>Collections synced:</strong> {{ collections|join(", ") }}</p>
180
-
<p><strong>Total records in database:</strong> {{ total_records }}</p>
181
-
<p><strong>Operation:</strong> {{ message }}</p>
182
-
</div>
183
-
"#).unwrap();
184
-
185
-
let tmpl = env.get_template("sync_result.html").unwrap();
186
-
let rendered = tmpl.render(context! {
187
-
collections => collections,
188
-
total_records => total_records,
189
-
message => "Bulk sync operation completed"
190
-
}).unwrap();
191
-
192
-
Ok(Html(rendered))
193
-
},
194
-
Err(e) => {
195
-
Ok(Html(format!(r#"
196
-
<div class="alert alert-error">
197
-
<h4>❌ Bulk sync failed</h4>
198
-
<p>Error: {}</p>
199
-
</div>
200
-
"#, e)))
201
-
}
202
-
}
203
-
}
204
-
-70
api/templates/base.html
-70
api/templates/base.html
···
1
-
<!DOCTYPE html>
2
-
<html lang="en">
3
-
4
-
<head>
5
-
<meta charset="UTF-8">
6
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7
-
<title>{{ title }} - AT Protocol Indexer</title>
8
-
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
9
-
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
10
-
<script src="https://cdn.tailwindcss.com/3.4.1"></script>
11
-
<style>
12
-
.alert {
13
-
padding: 1rem;
14
-
margin-bottom: 1rem;
15
-
border-radius: 0.375rem;
16
-
border-width: 1px;
17
-
}
18
-
19
-
.alert-success {
20
-
background-color: #dcfce7;
21
-
border-color: #22c55e;
22
-
color: #15803d;
23
-
}
24
-
25
-
.alert-warning {
26
-
background-color: #fef3c7;
27
-
border-color: #eab308;
28
-
color: #a16207;
29
-
}
30
-
31
-
.alert-error {
32
-
background-color: #fecaca;
33
-
border-color: #ef4444;
34
-
color: #dc2626;
35
-
}
36
-
37
-
.htmx-indicator {
38
-
display: none;
39
-
}
40
-
41
-
.htmx-request .htmx-indicator {
42
-
display: inline;
43
-
}
44
-
45
-
.htmx-request .default-text {
46
-
display: none;
47
-
}
48
-
</style>
49
-
</head>
50
-
51
-
<body class="bg-gray-100 min-h-screen">
52
-
<nav class="bg-blue-600 text-white p-4">
53
-
<div class="container mx-auto flex justify-between items-center">
54
-
<h1 class="text-xl font-bold">AT Protocol Indexer</h1>
55
-
<div class="space-x-4">
56
-
<a href="/" class="hover:underline">Home</a>
57
-
<a href="/records" class="hover:underline">Records</a>
58
-
<a href="/lexicon" class="hover:underline">Lexicon</a>
59
-
<a href="/sync" class="hover:underline">Sync</a>
60
-
<a href="/codegen" class="hover:underline">Codegen</a>
61
-
</div>
62
-
</div>
63
-
</nav>
64
-
65
-
<main class="container mx-auto mt-8 px-4">
66
-
{% block content %}{% endblock %}
67
-
</main>
68
-
</body>
69
-
70
-
</html>
-82
api/templates/codegen.html
-82
api/templates/codegen.html
···
1
-
{% extends "base.html" %}
2
-
3
-
{% block content %}
4
-
<div class="max-w-6xl mx-auto">
5
-
<h1 class="text-3xl font-bold text-gray-800 mb-8">Client Code Generation</h1>
6
-
7
-
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
8
-
<h2 class="text-xl font-semibold mb-4">Generate Typed Clients</h2>
9
-
<p class="text-gray-600 mb-6">Generate TypeScript clients for interacting with XRPC APIs based on stored lexicon definitions.</p>
10
-
11
-
<form hx-post="/codegen/generate"
12
-
hx-target="#codegen-result"
13
-
hx-indicator="#generate-button"
14
-
class="space-y-6">
15
-
<div>
16
-
<label for="target" class="block text-sm font-medium text-gray-700 mb-2">Target Language</label>
17
-
<select name="target" id="target" class="w-full border border-gray-300 rounded-md px-3 py-2">
18
-
<option value="typescript-deno">TypeScript (Deno)</option>
19
-
</select>
20
-
</div>
21
-
22
-
<div>
23
-
<label for="client_type" class="block text-sm font-medium text-gray-700 mb-2">Client Type</label>
24
-
<select name="client_type" id="client_type" class="w-full border border-gray-300 rounded-md px-3 py-2">
25
-
<option value="records">Records Client</option>
26
-
</select>
27
-
</div>
28
-
29
-
<div>
30
-
<label class="block text-sm font-medium text-gray-700 mb-2">Include Collections</label>
31
-
<div class="space-y-2 max-h-64 overflow-y-auto border border-gray-300 rounded-md p-3">
32
-
{% if lexicons %}
33
-
{% for lexicon in lexicons %}
34
-
<div class="flex items-center">
35
-
<input type="checkbox"
36
-
id="lexicon-{{ lexicon.nsid }}"
37
-
name="lexicons"
38
-
value="{{ lexicon.nsid }}"
39
-
class="mr-2">
40
-
<label for="lexicon-{{ lexicon.nsid }}" class="text-sm text-gray-600 font-mono">{{ lexicon.nsid }}</label>
41
-
</div>
42
-
{% endfor %}
43
-
{% else %}
44
-
<p class="text-gray-500 text-sm">No lexicons available. Upload some lexicon files first.</p>
45
-
{% endif %}
46
-
</div>
47
-
</div>
48
-
49
-
<button type="submit"
50
-
id="generate-button"
51
-
class="w-full bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-md font-medium">
52
-
<span class="default-text">Generate Client</span>
53
-
<span class="htmx-indicator">🔄 Generating...</span>
54
-
</button>
55
-
</form>
56
-
57
-
<div id="codegen-result" class="mt-6"></div>
58
-
</div>
59
-
60
-
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6">
61
-
<h3 class="text-lg font-semibold text-blue-800 mb-2">💡 About Client Generation</h3>
62
-
<ul class="space-y-2 text-blue-700">
63
-
<li class="flex items-start">
64
-
<span class="font-bold mr-2">•</span>
65
-
<span>Generates typed TypeScript clients for interacting with XRPC APIs</span>
66
-
</li>
67
-
<li class="flex items-start">
68
-
<span class="font-bold mr-2">•</span>
69
-
<span>Based on stored lexicon definitions with full type safety</span>
70
-
</li>
71
-
<li class="flex items-start">
72
-
<span class="font-bold mr-2">•</span>
73
-
<span>Includes interfaces for records, queries, and procedures</span>
74
-
</li>
75
-
<li class="flex items-start">
76
-
<span class="font-bold mr-2">•</span>
77
-
<span>Compatible with Deno and modern TypeScript environments</span>
78
-
</li>
79
-
</ul>
80
-
</div>
81
-
</div>
82
-
{% endblock %}
-94
api/templates/index.html
-94
api/templates/index.html
···
1
-
{% extends "base.html" %}
2
-
3
-
{% block content %}
4
-
<div class="max-w-4xl mx-auto">
5
-
<h1 class="text-3xl font-bold text-gray-800 mb-8">AT Protocol Indexer</h1>
6
-
7
-
{% if total_records > 0 %}
8
-
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
9
-
<h2 class="text-xl font-semibold text-blue-800 mb-2">📊 Database Status</h2>
10
-
<p class="text-blue-700">Currently indexing <strong>{{ total_records }}</strong> records across <strong>{{
11
-
collections|length }}</strong> collections.</p>
12
-
</div>
13
-
{% endif %}
14
-
15
-
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
16
-
<div class="bg-white rounded-lg shadow-md p-6">
17
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">📝 View Records</h2>
18
-
<p class="text-gray-600 mb-4">Browse indexed AT Protocol records by collection.</p>
19
-
{% if collections %}
20
-
<a href="/records" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
21
-
Browse Records
22
-
</a>
23
-
{% else %}
24
-
<p class="text-gray-500 text-sm">No records synced yet. Start by syncing some records!</p>
25
-
{% endif %}
26
-
</div>
27
-
28
-
<div class="bg-white rounded-lg shadow-md p-6">
29
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">📚 Lexicon Definitions</h2>
30
-
<p class="text-gray-600 mb-4">View lexicon definitions and schemas.</p>
31
-
<a href="/lexicon" class="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded">
32
-
View Lexicons
33
-
</a>
34
-
</div>
35
-
36
-
<div class="bg-white rounded-lg shadow-md p-6">
37
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">⚡ Code Generation</h2>
38
-
<p class="text-gray-600 mb-4">Generate TypeScript client from your lexicon definitions.</p>
39
-
<a href="/codegen" class="bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded">
40
-
Generate Client
41
-
</a>
42
-
</div>
43
-
44
-
<div class="bg-white rounded-lg shadow-md p-6">
45
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">🔄 Bulk Sync</h2>
46
-
<p class="text-gray-600 mb-4">Sync entire collections from AT Protocol networks.</p>
47
-
<a href="/sync" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">
48
-
Start Bulk Sync
49
-
</a>
50
-
</div>
51
-
52
-
{% if collections %}
53
-
<div class="bg-white rounded-lg shadow-md p-6">
54
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">📊 Synced Collections</h2>
55
-
<p class="text-gray-600 mb-4">Collections currently indexed in the database.</p>
56
-
<div class="space-y-2 max-h-40 overflow-y-auto">
57
-
{% for collection in collections %}
58
-
<a href="/records?collection={{ collection[0] }}"
59
-
class="flex justify-between items-center text-blue-600 hover:underline text-sm">
60
-
<span>{{ collection[0] }}</span>
61
-
<span class="text-gray-500">{{ collection[1] }}</span>
62
-
</a>
63
-
{% endfor %}
64
-
</div>
65
-
</div>
66
-
{% else %}
67
-
<div class="bg-white rounded-lg shadow-md p-6">
68
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">🌟 Get Started</h2>
69
-
<p class="text-gray-600 mb-4">No records indexed yet. Start by syncing some AT Protocol collections!</p>
70
-
<div class="space-y-2 text-sm">
71
-
<p class="text-gray-500">Try syncing collections like:</p>
72
-
<code class="block bg-gray-100 p-2 rounded text-xs">app.bsky.feed.post</code>
73
-
<code class="block bg-gray-100 p-2 rounded text-xs">app.bsky.actor.profile</code>
74
-
</div>
75
-
</div>
76
-
{% endif %}
77
-
</div>
78
-
79
-
<div class="mt-12 bg-white rounded-lg shadow-md p-6">
80
-
<h2 class="text-2xl font-semibold text-gray-800 mb-4">API Endpoints</h2>
81
-
<div class="space-y-4">
82
-
<div>
83
-
<code
84
-
class="bg-gray-100 px-2 py-1 rounded">GET /xrpc/social.slices.records.list?collection=app.bsky.feed.post</code>
85
-
<p class="text-gray-600 mt-1">List records for a collection</p>
86
-
</div>
87
-
<div>
88
-
<code class="bg-gray-100 px-2 py-1 rounded">POST /xrpc/social.slices.collections.bulkSync</code>
89
-
<p class="text-gray-600 mt-1">Bulk sync collections (JSON: {"collections": ["app.bsky.feed.post"]})</p>
90
-
</div>
91
-
</div>
92
-
</div>
93
-
</div>
94
-
{% endblock %}
-49
api/templates/lexicon.html
-49
api/templates/lexicon.html
···
1
-
{% extends "base.html" %}
2
-
3
-
{% block content %}
4
-
<div class="max-w-6xl mx-auto">
5
-
<h1 class="text-3xl font-bold text-gray-800 mb-8">Lexicon Definitions</h1>
6
-
7
-
{% if lexicons %}
8
-
<div class="bg-white rounded-lg shadow-md overflow-hidden">
9
-
<div class="px-6 py-4 bg-gray-50 border-b">
10
-
<h3 class="text-lg font-semibold">Found {{ lexicons|length }} lexicon definitions</h3>
11
-
</div>
12
-
<div class="divide-y divide-gray-200">
13
-
{% for lexicon in lexicons %}
14
-
<div class="p-6">
15
-
<div class="flex justify-between items-start mb-4">
16
-
<h4 class="text-lg font-medium text-blue-600">{{ lexicon.nsid }}</h4>
17
-
<span class="text-xs text-gray-500">Updated: {{ lexicon.updated_at }}</span>
18
-
</div>
19
-
20
-
<div class="mt-4">
21
-
<details class="cursor-pointer">
22
-
<summary class="font-medium text-gray-700 hover:text-gray-900">View Definitions</summary>
23
-
<div class="mt-4">
24
-
{% if lexicon.pretty_definitions %}
25
-
<pre class="bg-gray-100 p-3 rounded text-xs overflow-x-auto">{{ lexicon.pretty_definitions }}</pre>
26
-
{% else %}
27
-
<p class="text-gray-500 italic">No definitions found</p>
28
-
{% endif %}
29
-
</div>
30
-
</details>
31
-
</div>
32
-
</div>
33
-
{% endfor %}
34
-
</div>
35
-
</div>
36
-
{% else %}
37
-
<div class="bg-white rounded-lg shadow-md p-8 text-center">
38
-
<div class="text-gray-400 text-6xl mb-4">📚</div>
39
-
<h3 class="text-xl font-semibold text-gray-800 mb-2">No lexicon definitions found</h3>
40
-
<p class="text-gray-600 mb-4">
41
-
Upload lexicon files to see their definitions here.
42
-
</p>
43
-
<a href="/sync" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
44
-
Upload Lexicons
45
-
</a>
46
-
</div>
47
-
{% endif %}
48
-
</div>
49
-
{% endblock %}
-98
api/templates/records.html
-98
api/templates/records.html
···
1
-
{% extends "base.html" %}
2
-
3
-
{% block content %}
4
-
<div class="max-w-6xl mx-auto">
5
-
<h1 class="text-3xl font-bold text-gray-800 mb-8">Records</h1>
6
-
7
-
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
8
-
<div class="flex justify-between items-center mb-4">
9
-
<h2 class="text-xl font-semibold">Filter Records</h2>
10
-
</div>
11
-
<form class="grid grid-cols-1 md:grid-cols-3 gap-4" method="get" _="on submit
12
-
if #author.value is empty
13
-
remove @name from #author
14
-
end">
15
-
<div>
16
-
<label class="block text-sm font-medium text-gray-700 mb-2">Collection</label>
17
-
<select name="collection" class="w-full border border-gray-300 rounded-md px-3 py-2">
18
-
<option value="">Select collection...</option>
19
-
{% for available_collection in available_collections %}
20
-
<option value="{{ available_collection[0] }}" {% if collection==available_collection[0] %}selected{%
21
-
endif %}>
22
-
{{ available_collection[0] }} ({{ available_collection[1] }} records)
23
-
</option>
24
-
{% endfor %}
25
-
</select>
26
-
</div>
27
-
<div>
28
-
<label class="block text-sm font-medium text-gray-700 mb-2">Author DID</label>
29
-
<input type="text" id="author" name="author" placeholder="did:plc:..."
30
-
class="w-full border border-gray-300 rounded-md px-3 py-2">
31
-
</div>
32
-
<div class="flex items-end">
33
-
<button type="submit" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md">
34
-
Filter
35
-
</button>
36
-
</div>
37
-
</form>
38
-
</div>
39
-
40
-
{% if records %}
41
-
<div class="bg-white rounded-lg shadow-md overflow-hidden">
42
-
<div class="px-6 py-4 bg-gray-50 border-b">
43
-
<h3 class="text-lg font-semibold">Found {{ records|length }} records</h3>
44
-
</div>
45
-
<div class="divide-y divide-gray-200">
46
-
{% for record in records %}
47
-
<div class="p-6">
48
-
<div class="flex justify-between items-start mb-2">
49
-
<h4 class="text-sm font-medium text-blue-600">{{ record.uri }}</h4>
50
-
<span class="text-xs text-gray-500">{{ record.indexed_at }}</span>
51
-
</div>
52
-
<div class="space-y-2 text-sm">
53
-
<div>
54
-
<span class="font-medium">Collection:</span>
55
-
<span class="text-gray-600">{{ record.collection }}</span>
56
-
</div>
57
-
<div>
58
-
<span class="font-medium">Author:</span>
59
-
<span class="text-gray-600">{{ record.did }}</span>
60
-
</div>
61
-
<div>
62
-
<span class="font-medium">CID:</span>
63
-
<span class="text-gray-600 font-mono text-xs">{{ record.cid }}</span>
64
-
</div>
65
-
</div>
66
-
{% if record.value %}
67
-
<div class="mt-4">
68
-
<details class="cursor-pointer">
69
-
<summary class="font-medium text-gray-700 hover:text-gray-900">View Record Data</summary>
70
-
<pre
71
-
class="mt-2 bg-gray-100 p-3 rounded text-xs overflow-x-auto">{{ record.pretty_value }}</pre>
72
-
</details>
73
-
</div>
74
-
{% endif %}
75
-
</div>
76
-
{% endfor %}
77
-
</div>
78
-
</div>
79
-
{% else %}
80
-
<div class="bg-white rounded-lg shadow-md p-8 text-center">
81
-
<div class="text-gray-400 text-6xl mb-4">📝</div>
82
-
<h3 class="text-xl font-semibold text-gray-800 mb-2">No records found</h3>
83
-
<p class="text-gray-600 mb-4">
84
-
{% if collection %}
85
-
No records found for collection "{{ collection }}".
86
-
{% elif available_collections %}
87
-
Select a collection from the dropdown above to view records.
88
-
{% else %}
89
-
No records have been synced yet. Start by syncing some AT Protocol records!
90
-
{% endif %}
91
-
</p>
92
-
<a href="/sync" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
93
-
Sync Some Records
94
-
</a>
95
-
</div>
96
-
{% endif %}
97
-
</div>
98
-
{% endblock %}
-122
api/templates/sync.html
-122
api/templates/sync.html
···
1
-
{% extends "base.html" %}
2
-
3
-
{% block content %}
4
-
<div class="max-w-4xl mx-auto">
5
-
<h1 class="text-3xl font-bold text-gray-800 mb-8">Sync Records</h1>
6
-
7
-
<div class="max-w-2xl mx-auto">
8
-
<!-- Lexicon Upload Section -->
9
-
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
10
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">📦 Upload Lexicon Files</h2>
11
-
<p class="text-gray-600 mb-6">Upload a zip file containing lexicon JSON files to automatically populate collections for syncing.</p>
12
-
13
-
<form hx-post="/upload-lexicons"
14
-
hx-target="#lexicon-result"
15
-
hx-indicator="#upload-button"
16
-
enctype="multipart/form-data"
17
-
class="space-y-4">
18
-
<div>
19
-
<label for="lexicon-file" class="block text-sm font-medium text-gray-700 mb-2">Lexicon Zip File</label>
20
-
<input type="file"
21
-
id="lexicon-file"
22
-
name="lexicon_file"
23
-
accept=".zip"
24
-
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-green-500 focus:border-green-500"
25
-
required>
26
-
<p class="text-xs text-gray-500 mt-1">
27
-
Upload a zip file containing lexicon JSON files. Record definitions will be extracted automatically.
28
-
</p>
29
-
</div>
30
-
31
-
<button type="submit"
32
-
id="upload-button"
33
-
class="w-full bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-md font-medium">
34
-
<span class="default-text">Parse Lexicons</span>
35
-
<span class="htmx-indicator">🔄 Processing...</span>
36
-
</button>
37
-
</form>
38
-
39
-
<div id="lexicon-result" class="mt-4"></div>
40
-
</div>
41
-
42
-
<!-- Bulk Sync Section -->
43
-
<div class="bg-white rounded-lg shadow-md p-6">
44
-
<h2 class="text-xl font-semibold text-gray-800 mb-4">📚 Bulk Sync Collections</h2>
45
-
<p class="text-gray-600 mb-6">Sync multiple records from AT Protocol collections. This will fetch records from across the network.</p>
46
-
47
-
<form hx-post="/sync"
48
-
hx-target="#sync-result"
49
-
hx-indicator="#sync-button"
50
-
class="space-y-6">
51
-
<div>
52
-
<label for="collections" class="block text-sm font-medium text-gray-700 mb-2">Collections to Sync</label>
53
-
<input type="text"
54
-
id="collections"
55
-
name="collections"
56
-
placeholder="app.bsky.feed.post, app.bsky.actor.profile"
57
-
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
58
-
required>
59
-
<p class="text-xs text-gray-500 mt-1">
60
-
Enter collection names separated by commas. Common collections: app.bsky.feed.post, app.bsky.actor.profile, app.bsky.feed.like
61
-
</p>
62
-
</div>
63
-
64
-
<div>
65
-
<label for="repos" class="block text-sm font-medium text-gray-700 mb-2">Specific DIDs (optional)</label>
66
-
<textarea id="repos"
67
-
name="repos"
68
-
placeholder="did:plc:example1 did:plc:example2"
69
-
rows="4"
70
-
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
71
-
<p class="text-xs text-gray-500 mt-1">Optional: Enter specific DIDs (one per line). Leave empty to discover and sync from all repositories that have the specified collections.</p>
72
-
</div>
73
-
74
-
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
75
-
<div class="flex">
76
-
<div class="flex-shrink-0">
77
-
<span class="text-yellow-600">⚠️</span>
78
-
</div>
79
-
<div class="ml-3">
80
-
<h3 class="text-sm font-medium text-yellow-800">Note about bulk sync</h3>
81
-
<div class="mt-2 text-sm text-yellow-700">
82
-
<p>Bulk sync can take several minutes and may fetch thousands of records. The operation runs in the foreground, so please be patient.</p>
83
-
</div>
84
-
</div>
85
-
</div>
86
-
</div>
87
-
88
-
<button type="submit"
89
-
id="sync-button"
90
-
class="w-full bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-md font-medium">
91
-
<span class="default-text">Start Bulk Sync</span>
92
-
<span class="htmx-indicator">🔄 Syncing...</span>
93
-
</button>
94
-
</form>
95
-
96
-
<div id="sync-result" class="mt-6"></div>
97
-
</div>
98
-
</div>
99
-
100
-
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
101
-
<h3 class="text-lg font-semibold text-blue-800 mb-2">💡 Tips for Bulk Sync</h3>
102
-
<ul class="space-y-2 text-blue-700">
103
-
<li class="flex items-start">
104
-
<span class="font-bold mr-2">•</span>
105
-
<span>Start with popular collections like <code class="bg-blue-100 px-1 rounded">app.bsky.feed.post</code> for posts</span>
106
-
</li>
107
-
<li class="flex items-start">
108
-
<span class="font-bold mr-2">•</span>
109
-
<span>Leave DIDs empty to discover all repositories automatically</span>
110
-
</li>
111
-
<li class="flex items-start">
112
-
<span class="font-bold mr-2">•</span>
113
-
<span>Use the API endpoint for programmatic access: <code class="bg-blue-100 px-1 rounded">/xrpc/social.slices.collections.bulkSync</code></span>
114
-
</li>
115
-
<li class="flex items-start">
116
-
<span class="font-bold mr-2">•</span>
117
-
<span>Sync operations may take time - progress is logged to the server console</span>
118
-
</li>
119
-
</ul>
120
-
</div>
121
-
</div>
122
-
{% endblock %}
+52
-45
frontend/src/client.ts
+52
-45
frontend/src/client.ts
···
1
1
// Generated TypeScript client for AT Protocol records
2
-
// Generated at: 2025-08-21 22:15:58 UTC
2
+
// Generated at: 2025-08-22 22:17:55 UTC
3
3
// Lexicons: 2
4
4
5
5
export interface OAuthAuthorizeParams {
···
73
73
getRecord(params: GetRecordParams): Promise<RecordResponse<T>>;
74
74
}
75
75
76
-
export interface XyzSliceatLexiconRecord {
77
-
/** When the lexicon was created */
78
-
createdAt: string;
79
-
/** The lexicon schema definitions as JSON */
80
-
definitions: string;
76
+
export interface SocialSlicesLexiconRecord {
81
77
/** Namespaced identifier for the lexicon */
82
78
nsid: string;
83
-
/** AT-URI reference to the slice this lexicon belongs to */
84
-
slice: string;
79
+
/** The lexicon schema definitions as JSON */
80
+
definitions: string;
81
+
/** When the lexicon was created */
82
+
createdAt: string;
85
83
/** When the lexicon was last updated */
86
84
updatedAt?: string;
85
+
/** AT-URI reference to the slice this lexicon belongs to */
86
+
slice: string;
87
87
}
88
88
89
-
export interface XyzSliceatSliceRecord {
89
+
export interface SocialSlicesSliceRecord {
90
+
/** Name of the slice */
91
+
name: string;
90
92
/** When the slice was created */
91
93
createdAt: string;
92
-
/** Name of the slice */
93
-
name: string;
94
94
}
95
95
96
96
export class PKCEUtils {
···
434
434
}
435
435
}
436
436
437
-
class LexiconSliceatXyzClient extends BaseClient {
437
+
class LexiconSlicesSocialClient extends BaseClient {
438
438
constructor(
439
439
baseUrl: string,
440
440
authBaseUrl: string,
···
446
446
447
447
async listRecords(
448
448
params?: ListRecordsParams
449
-
): Promise<ListRecordsResponse<XyzSliceatLexiconRecord>> {
450
-
return await this.makeRequest("xyz.sliceat.lexicon.list", "GET", params);
449
+
): Promise<ListRecordsResponse<SocialSlicesLexiconRecord>> {
450
+
return await this.makeRequest("social.slices.lexicon.list", "GET", params);
451
451
}
452
452
453
453
async getRecord(
454
454
params: GetRecordParams
455
-
): Promise<RecordResponse<XyzSliceatLexiconRecord>> {
456
-
return await this.makeRequest("xyz.sliceat.lexicon.get", "GET", params);
455
+
): Promise<RecordResponse<SocialSlicesLexiconRecord>> {
456
+
return await this.makeRequest("social.slices.lexicon.get", "GET", params);
457
457
}
458
458
459
459
async createRecord(
460
-
record: XyzSliceatLexiconRecord
460
+
record: SocialSlicesLexiconRecord
461
461
): Promise<{ uri: string; cid: string }> {
462
-
const recordWithType = { $type: "xyz.sliceat.lexicon", ...record };
462
+
const recordWithType = { $type: "social.slices.lexicon", ...record };
463
463
return await this.makeRequest(
464
-
"xyz.sliceat.lexicon.create",
464
+
"social.slices.lexicon.create",
465
465
"POST",
466
466
recordWithType
467
467
);
···
469
469
470
470
async updateRecord(
471
471
rkey: string,
472
-
record: XyzSliceatLexiconRecord
472
+
record: SocialSlicesLexiconRecord
473
473
): Promise<{ uri: string; cid: string }> {
474
-
const recordWithType = { $type: "xyz.sliceat.lexicon", ...record };
475
-
return await this.makeRequest("xyz.sliceat.lexicon.update", "POST", {
474
+
const recordWithType = { $type: "social.slices.lexicon", ...record };
475
+
return await this.makeRequest("social.slices.lexicon.update", "POST", {
476
476
rkey,
477
477
record: recordWithType,
478
478
});
479
479
}
480
480
481
481
async deleteRecord(rkey: string): Promise<void> {
482
-
return await this.makeRequest("xyz.sliceat.lexicon.delete", "POST", {
482
+
return await this.makeRequest("social.slices.lexicon.delete", "POST", {
483
483
rkey,
484
484
});
485
485
}
486
486
}
487
487
488
-
class SliceSliceatXyzClient extends BaseClient {
488
+
class SliceSlicesSocialClient extends BaseClient {
489
489
constructor(
490
490
baseUrl: string,
491
491
authBaseUrl: string,
···
497
497
498
498
async listRecords(
499
499
params?: ListRecordsParams
500
-
): Promise<ListRecordsResponse<XyzSliceatSliceRecord>> {
501
-
return await this.makeRequest("xyz.sliceat.slice.list", "GET", params);
500
+
): Promise<ListRecordsResponse<SocialSlicesSliceRecord>> {
501
+
return await this.makeRequest("social.slices.slice.list", "GET", params);
502
502
}
503
503
504
504
async getRecord(
505
505
params: GetRecordParams
506
-
): Promise<RecordResponse<XyzSliceatSliceRecord>> {
507
-
return await this.makeRequest("xyz.sliceat.slice.get", "GET", params);
506
+
): Promise<RecordResponse<SocialSlicesSliceRecord>> {
507
+
return await this.makeRequest("social.slices.slice.get", "GET", params);
508
508
}
509
509
510
510
async createRecord(
511
-
record: XyzSliceatSliceRecord
511
+
record: SocialSlicesSliceRecord
512
512
): Promise<{ uri: string; cid: string }> {
513
-
const recordWithType = { $type: "xyz.sliceat.slice", ...record };
513
+
const recordWithType = { $type: "social.slices.slice", ...record };
514
514
return await this.makeRequest(
515
-
"xyz.sliceat.slice.create",
515
+
"social.slices.slice.create",
516
516
"POST",
517
517
recordWithType
518
518
);
···
520
520
521
521
async updateRecord(
522
522
rkey: string,
523
-
record: XyzSliceatSliceRecord
523
+
record: SocialSlicesSliceRecord
524
524
): Promise<{ uri: string; cid: string }> {
525
-
const recordWithType = { $type: "xyz.sliceat.slice", ...record };
526
-
return await this.makeRequest("xyz.sliceat.slice.update", "POST", {
525
+
const recordWithType = { $type: "social.slices.slice", ...record };
526
+
return await this.makeRequest("social.slices.slice.update", "POST", {
527
527
rkey,
528
528
record: recordWithType,
529
529
});
530
530
}
531
531
532
532
async deleteRecord(rkey: string): Promise<void> {
533
-
return await this.makeRequest("xyz.sliceat.slice.delete", "POST", { rkey });
533
+
return await this.makeRequest("social.slices.slice.delete", "POST", {
534
+
rkey,
535
+
});
534
536
}
535
537
}
536
538
537
-
class SliceatXyzClient extends BaseClient {
538
-
readonly lexicon: LexiconSliceatXyzClient;
539
-
readonly slice: SliceSliceatXyzClient;
539
+
class SlicesSocialClient extends BaseClient {
540
+
readonly lexicon: LexiconSlicesSocialClient;
541
+
readonly slice: SliceSlicesSocialClient;
540
542
541
543
constructor(
542
544
baseUrl: string,
···
545
547
clientSecret: string
546
548
) {
547
549
super(baseUrl, authBaseUrl, clientId, clientSecret);
548
-
this.lexicon = new LexiconSliceatXyzClient(
550
+
this.lexicon = new LexiconSlicesSocialClient(
549
551
baseUrl,
550
552
authBaseUrl,
551
553
clientId,
552
554
clientSecret
553
555
);
554
-
this.slice = new SliceSliceatXyzClient(
556
+
this.slice = new SliceSlicesSocialClient(
555
557
baseUrl,
556
558
authBaseUrl,
557
559
clientId,
···
560
562
}
561
563
}
562
564
563
-
class XyzClient extends BaseClient {
564
-
readonly sliceat: SliceatXyzClient;
565
+
class SocialClient extends BaseClient {
566
+
readonly slices: SlicesSocialClient;
565
567
566
568
constructor(
567
569
baseUrl: string,
···
570
572
clientSecret: string
571
573
) {
572
574
super(baseUrl, authBaseUrl, clientId, clientSecret);
573
-
this.sliceat = new SliceatXyzClient(
575
+
this.slices = new SlicesSocialClient(
574
576
baseUrl,
575
577
authBaseUrl,
576
578
clientId,
···
580
582
}
581
583
582
584
export class AtProtoClient extends BaseClient {
583
-
readonly xyz: XyzClient;
585
+
readonly social: SocialClient;
584
586
readonly oauth: OAuthClient;
585
587
586
588
constructor(
···
590
592
clientSecret: string
591
593
) {
592
594
super(baseUrl, authBaseUrl, clientId, clientSecret);
593
-
this.xyz = new XyzClient(baseUrl, authBaseUrl, clientId, clientSecret);
595
+
this.social = new SocialClient(
596
+
baseUrl,
597
+
authBaseUrl,
598
+
clientId,
599
+
clientSecret
600
+
);
594
601
this.oauth = new OAuthClient(baseUrl, authBaseUrl, clientId, clientSecret);
595
602
}
596
603
}
+11
-14
frontend/src/pages/SliceLexiconPage.tsx
+11
-14
frontend/src/pages/SliceLexiconPage.tsx
···
26
26
<div className="max-w-4xl mx-auto">
27
27
<div className="flex items-center justify-between mb-8">
28
28
<div className="flex items-center">
29
-
<a
30
-
href="/"
31
-
className="text-blue-600 hover:text-blue-800 mr-4"
32
-
>
29
+
<a href="/" className="text-blue-600 hover:text-blue-800 mr-4">
33
30
← Back to Slices
34
31
</a>
35
-
<h1 className="text-3xl font-bold text-gray-800">
36
-
{sliceName}
37
-
</h1>
32
+
<h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1>
38
33
</div>
39
34
</div>
40
35
···
82
77
className="block w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm"
83
78
placeholder={`{
84
79
"lexicon": 1,
85
-
"id": "xyz.sliceat.example",
80
+
"id": "social.slices.example",
86
81
"description": "Example record type",
87
82
"defs": {
88
83
"main": {
···
119
114
Add Lexicon
120
115
</button>
121
116
</form>
122
-
117
+
123
118
<div id="lexicon-result" className="mt-4"></div>
124
119
</div>
125
120
···
128
123
Upload Lexicon Files
129
124
</h2>
130
125
<p className="text-gray-600 mb-6">
131
-
Or upload lexicon schema files to define custom record types for this slice.
126
+
Or upload lexicon schema files to define custom record types for
127
+
this slice.
132
128
</p>
133
129
134
130
<form
···
148
144
className="block w-full border border-gray-300 rounded-md px-3 py-2"
149
145
/>
150
146
<p className="text-sm text-gray-500 mt-1">
151
-
Upload a ZIP file containing lexicon definitions or a single JSON file
147
+
Upload a ZIP file containing lexicon definitions or a single
148
+
JSON file
152
149
</p>
153
150
</div>
154
151
···
167
164
Slice Lexicons
168
165
</h2>
169
166
</div>
170
-
<div
171
-
id="lexicon-list"
167
+
<div
168
+
id="lexicon-list"
172
169
className="p-6"
173
170
hx-get="/api/lexicons/list"
174
171
hx-trigger="load"
···
194
191
</div>
195
192
</Layout>
196
193
);
197
-
}
194
+
}
+14
-10
frontend/src/routes/pages.tsx
+14
-10
frontend/src/routes/pages.tsx
···
10
10
import { SliceLexiconPage } from "../pages/SliceLexiconPage.tsx";
11
11
import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx";
12
12
import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx";
13
+
import { buildAtUri } from "../utils/at-uri.ts";
13
14
14
15
async function handleIndexPage(req: Request): Promise<Response> {
15
16
const context = await withAuth(req);
···
19
20
20
21
if (context.currentUser.isAuthenticated) {
21
22
try {
22
-
const sliceRecords = await atprotoClient.xyz.sliceat.slice.listRecords();
23
+
const sliceRecords =
24
+
await atprotoClient.social.slices.slice.listRecords();
23
25
24
26
slices = sliceRecords.records.map((record) => {
25
27
// Extract slice ID from URI
···
105
107
// Construct the full URI for this slice
106
108
const sliceUri = `at://${
107
109
context.currentUser.sub || "unknown"
108
-
}/xyz.sliceat.slice/${sliceId}`;
110
+
}/social.slices.slice/${sliceId}`;
109
111
110
-
const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({
112
+
const sliceRecord = await atprotoClient.social.slices.slice.getRecord({
111
113
uri: sliceUri,
112
114
});
113
115
···
115
117
sliceId,
116
118
sliceName: sliceRecord.value.name,
117
119
totalRecords: 1, // For now, just showing this slice
118
-
collections: [{ name: "xyz.sliceat.slice", count: 1 }],
120
+
collections: [{ name: "social.slices.slice", count: 1 }],
119
121
};
120
-
} catch (error) {
122
+
} catch (_error) {
121
123
// Fall back to default data
122
124
}
123
125
}
···
167
169
if (context.currentUser.isAuthenticated) {
168
170
try {
169
171
// Construct the full URI for this slice
170
-
const sliceUri = `at://${
171
-
context.currentUser.sub || "unknown"
172
-
}/xyz.sliceat.slice/${sliceId}`;
172
+
const sliceUri = buildAtUri({
173
+
did: context.currentUser.sub ?? "unknown",
174
+
collection: "social.slices.slice",
175
+
rkey: sliceId,
176
+
});
173
177
174
-
const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({
178
+
const sliceRecord = await atprotoClient.social.slices.slice.getRecord({
175
179
uri: sliceUri,
176
180
});
177
181
···
179
183
sliceId,
180
184
sliceName: sliceRecord.value.name,
181
185
totalRecords: 1, // For now, just showing this slice
182
-
collections: [{ name: "xyz.sliceat.slice", count: 1 }],
186
+
collections: [{ name: "social.slices.slice", count: 1 }],
183
187
};
184
188
} catch (error) {
185
189
console.error("Failed to fetch slice:", error);
+18
-12
frontend/src/routes/slices.tsx
+18
-12
frontend/src/routes/slices.tsx
···
42
42
43
43
// Create actual slice using AT Protocol
44
44
try {
45
-
const result = await atprotoClient.xyz.sliceat.slice.createRecord({
45
+
const result = await atprotoClient.social.slices.slice.createRecord({
46
46
name: name.trim(),
47
47
createdAt: new Date().toISOString(),
48
48
});
49
49
50
-
// Extract record key from URI (format: at://did:plc:example/xyz.sliceat.slice/rkey)
50
+
// Extract record key from URI (format: at://did:plc:example/social.slices.slice/rkey)
51
51
const uriParts = result.uri.split("/");
52
52
const sliceId = uriParts[uriParts.length - 1];
53
53
···
108
108
}
109
109
110
110
// Construct the URI for this slice
111
-
const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/${sliceId}`;
111
+
const sliceUri = `at://${context.currentUser.sub}/social.slices.slice/${sliceId}`;
112
112
113
113
// Get the current record first
114
-
const currentRecord = await atprotoClient.xyz.sliceat.slice.getRecord({
114
+
const currentRecord = await atprotoClient.social.slices.slice.getRecord({
115
115
uri: sliceUri,
116
116
});
117
117
···
121
121
name: name.trim(),
122
122
};
123
123
124
-
await atprotoClient.xyz.sliceat.slice.updateRecord(sliceId, updatedRecord);
124
+
await atprotoClient.social.slices.slice.updateRecord(
125
+
sliceId,
126
+
updatedRecord
127
+
);
125
128
126
129
const resultHtml = render(
127
130
<UpdateResult
···
148
151
}
149
152
}
150
153
151
-
async function handleDeleteSlice(req: Request, params?: URLPatternResult): Promise<Response> {
154
+
async function handleDeleteSlice(
155
+
req: Request,
156
+
params?: URLPatternResult
157
+
): Promise<Response> {
152
158
const context = await withAuth(req);
153
159
const authResponse = requireAuth(context, req);
154
160
if (authResponse) return authResponse;
···
160
166
161
167
try {
162
168
// Delete the slice record from AT Protocol
163
-
await atprotoClient.xyz.sliceat.slice.deleteRecord(sliceId);
169
+
await atprotoClient.social.slices.slice.deleteRecord(sliceId);
164
170
165
171
// Redirect to home page
166
172
return new Response("", {
···
182
188
try {
183
189
// Fetch lexicons from AT Protocol
184
190
const lexiconRecords =
185
-
await atprotoClient.xyz.sliceat.lexicon.listRecords();
191
+
await atprotoClient.social.slices.lexicon.listRecords();
186
192
187
193
if (lexiconRecords.records.length === 0) {
188
194
const html = render(<EmptyLexiconState />);
···
261
267
// Create the lexicon record
262
268
try {
263
269
// For now, we'll create a simple slice reference - this could be improved to reference a specific slice
264
-
const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/example`;
270
+
const sliceUri = `at://${context.currentUser.sub}/social.slices.slice/example`;
265
271
266
272
const lexiconRecord = {
267
273
nsid: lexiconData.id,
···
270
276
slice: sliceUri,
271
277
};
272
278
273
-
const result = await atprotoClient.xyz.sliceat.lexicon.createRecord(
279
+
const result = await atprotoClient.social.slices.lexicon.createRecord(
274
280
lexiconRecord
275
281
);
276
282
···
318
324
319
325
try {
320
326
// Delete the lexicon record from AT Protocol
321
-
await atprotoClient.xyz.sliceat.lexicon.deleteRecord(rkey);
327
+
await atprotoClient.social.slices.lexicon.deleteRecord(rkey);
322
328
323
329
// Check if there are any remaining lexicons
324
330
const remainingLexicons =
325
-
await atprotoClient.xyz.sliceat.lexicon.listRecords();
331
+
await atprotoClient.social.slices.lexicon.listRecords();
326
332
327
333
if (remainingLexicons.records.length === 0) {
328
334
// If no lexicons remain, return the empty state and target the parent list