diff --git a/Cargo.lock b/Cargo.lock index 1fe67d3..6edd2d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,7 @@ dependencies = [ "tangled-lexicon", "terminal_size", "tokio", + "userinput-lexicon", ] [[package]] @@ -3777,6 +3778,16 @@ dependencies = [ "serde", ] +[[package]] +name = "userinput-lexicon" +version = "0.1.0" +dependencies = [ + "jacquard-common", + "jacquard-derive", + "jacquard-lexicon", + "serde", +] + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index e8fd927..bc0ce0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,11 @@ tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi- # by scripts/vendor-tangled-lexicons.sh) rather than hand-written in # src/models.rs. See docs/vendored-dependencies.md and lexicons/README.md. tangled-lexicon = { path = "vendor/tangled-lexicon" } +# Generated from the vendored app.userinput.discussion lexicon +# (lexicons/app/userinput/, regenerated by scripts/vendor-userinput-lexicons.sh) +# rather than hand-read from the minified web client. See +# docs/vendored-dependencies.md and lexicons/README.md. +userinput-lexicon = { path = "vendor/userinput-lexicon" } # Vendored for two fixes, both worth reporting upstream: # diff --git a/docs/vendored-dependencies.md b/docs/vendored-dependencies.md index be1a65a..dc3d804 100644 --- a/docs/vendored-dependencies.md +++ b/docs/vendored-dependencies.md @@ -1,9 +1,10 @@ # Vendored dependencies There is one temporary patch of someone else's crate, and it should not be -permanent. `vendor/tangled-lexicon` is a different kind of `vendor/` -entry — generated code with nothing upstream to diverge from — and is -documented in [`lexicons/README.md`](../lexicons/README.md) instead. +permanent. `vendor/tangled-lexicon` and `vendor/userinput-lexicon` are a +different kind of `vendor/` entry — generated code with nothing upstream to +diverge from — and are documented in +[`lexicons/README.md`](../lexicons/README.md) instead. ## `vendor/jacquard-oauth` diff --git a/lexicons.json b/lexicons.json index 32e6f55..c1868e7 100644 --- a/lexicons.json +++ b/lexicons.json @@ -1,18 +1,119 @@ { - "version": 1, - "source": { - "repo": "https://tangled.org/tangled.org/core", - "path": "lexicons", - "commit": "74ce1a0f0c21749bdf15198a4bc7dc457696bf97", - "fetchedAt": "2026-08-11T11:28:19Z" + "version": 2, + "sources": { + "tangled": { + "type": "git", + "repo": "https://tangled.org/tangled.org/core", + "path": "lexicons", + "commit": "74ce1a0f0c21749bdf15198a4bc7dc457696bf97", + "fetchedAt": "2026-08-11T11:28:19Z" + }, + "userinput": { + "type": "atproto-records", + "authority": "did:plc:uyixj57k6nmxrdj7pjs2ss5s" + } }, - "lexicons": [ - "com.atproto.repo.strongRef", - "sh.tangled.feed.comment", - "sh.tangled.markup.markdown", - "sh.tangled.publicKey", - "sh.tangled.repo", - "sh.tangled.repo.pull", - "sh.tangled.repo.pull.status" - ] + "lexicons": { + "com.atproto.repo.strongRef": { + "source": "tangled" + }, + "sh.tangled.feed.comment": { + "source": "tangled" + }, + "sh.tangled.markup.markdown": { + "source": "tangled" + }, + "sh.tangled.publicKey": { + "source": "tangled" + }, + "sh.tangled.repo": { + "source": "tangled" + }, + "sh.tangled.repo.pull": { + "source": "tangled" + }, + "sh.tangled.repo.pull.status": { + "source": "tangled" + }, + "app.userinput.authBasic": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.authBasic", + "cid": "bafyreiaurw2j3w5wezw553oomjsifpxegayun52sibqbqdb4swof46kdoe" + }, + "app.userinput.authFull": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.authFull", + "cid": "bafyreibjffnlxsg7higoafvfcg6ijza7msc3h3hovwbrp3x3kyuogwrvmi" + }, + "app.userinput.ban": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.ban", + "cid": "bafyreifices2ubbtyswdavce75hsgqshp4iq6dktlk3c4pjrs2gdfnhyei" + }, + "app.userinput.checkedNotifications": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.checkedNotifications", + "cid": "bafyreiadtseawnnuom2jgc4ierv6lqr55drh2fkkgjqylfyzztbgbepgea" + }, + "app.userinput.discussion": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.discussion", + "cid": "bafyreia3pm5ywlis5lzl2lokic57wbsr4cez4tkrc7xicf6i76a72j7ey4" + }, + "app.userinput.downvote": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.downvote", + "cid": "bafyreibfyyjjohtdrhubfosshp2kpjmawid32zaabsmvyrdon7734gqd24" + }, + "app.userinput.edit": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.edit", + "cid": "bafyreiaen3yfyqbbmjw2bkb5i6ifolz6pb2cn4alcodquflfgfvbiyzuwa" + }, + "app.userinput.hide": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.hide", + "cid": "bafyreidx7hk553ayjxgdfk4b7vzembs2if6x7tud3al4ou4cxnr7si6k5m" + }, + "app.userinput.lock": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.lock", + "cid": "bafyreiacjwqnrgvxvwtjmy6u5d3p6z6ehgpb6dkeqjgqh7nvckp66hmovm" + }, + "app.userinput.member": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.member", + "cid": "bafyreic6q3r426skyazeyvcfqecgoybxk3wlqxxkstnjebxtmxpj2mn6iq" + }, + "app.userinput.pin": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.pin", + "cid": "bafyreiecjset6ufszxqrfxwckr25htyjlwda3tch7qf5idjheugxyvvtgy" + }, + "app.userinput.reply": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.reply", + "cid": "bafyreiawmqhu34mpi6cr3shtpe3ktlkxttdvwjfcq3h2ldjwsfwjq6smdm" + }, + "app.userinput.space": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.space", + "cid": "bafyreifzxvmlucnnqfxkbxq72ledmrjhdvt5ry3fqlpkacl7nmi6fnaohy" + }, + "app.userinput.status": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.status", + "cid": "bafyreihb5yxsnu2ho4zpw4o6pop4hwamzwrmz7xwq46ulpb2v2vq2z6344" + }, + "app.userinput.upvote": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.upvote", + "cid": "bafyreihhx6yslievy7xkx5wskkshhzlxm7tg6ya4jdbcawcbexfcoqusde" + }, + "app.userinput.visited": { + "source": "userinput", + "uri": "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/app.userinput.visited", + "cid": "bafyreicolxai426q2vjhezq6mh734is65nqoes36oztfqbc4wzkxtnobze" + } + } } diff --git a/lexicons/README.md b/lexicons/README.md index e409808..f9c3ab0 100644 --- a/lexicons/README.md +++ b/lexicons/README.md @@ -1,26 +1,62 @@ # lexicons -Vendored copies of the `sh.tangled.*` schemas atgc actually reads or writes, -plus `com.atproto.repo.strongRef` (needed by `feed.comment`'s `subject`/ -`replyTo`). Not edited here — see below. - -Unlike `app.userinput.*` (a different lexicon authority, pinned by at://-uri -and CID in the userinput.app report work), Tangled's lexicons are not -published as `com.atproto.lexicon.schema` records — `models.rs`'s own header -comment already named the real source of truth: -`tangled.org/tangled.org/core/tree/master/lexicons`, a git repo. So -`../lexicons.json` pins provenance the way that source actually offers it: the -commit these files were fetched at, not an at://-uri. +Vendored copies of every lexicon schema atgc reads or writes, from two +authorities with two different ways of publishing a schema — `../lexicons.json` +is the one manifest covering both, under a `sources` map keyed by which +convention applies. + +## `sh.tangled.*` and `com.atproto.repo.strongRef` `lexicons/sh/tangled/repo/pull.json` is `sh.tangled.repo.pull`, and so on — -path mirrors NSID, same convention `app.userinput.*` uses. +path mirrors NSID. `com.atproto.repo.strongRef` (needed by `feed.comment`'s +`subject`/`replyTo`) lives alongside them. + +Tangled's lexicons are not published as `com.atproto.lexicon.schema` +records — `models.rs`'s own header comment names the real source of truth: +`tangled.org/tangled.org/core/tree/master/lexicons`, a git repo. So the +`tangled` entry in `../lexicons.json`'s `sources` pins provenance the way +that source actually offers it: the commit these files were fetched at, not +an at://-uri. Refresh with `../scripts/vendor-tangled-lexicons.sh`, which re-clones the lexicons directory at its current tip, rewrites both the JSON here and the -generated Rust in `../vendor/tangled-lexicon/`, and updates the pinned commit. -A normal `cargo build` reads neither the network nor this script. +generated Rust in `../vendor/tangled-lexicon/`, and updates the pinned +commit. + +Scoped to exactly the seven lexicons the `tangled` source covers — not +Tangled's full surface (issues, CI, knot management, and every XRPC +endpoint atgc never calls). Widen +`scripts/vendor-tangled-lexicons.sh`'s codegen input if a future command +needs another one typed. + +## `app.userinput.*` + +`lexicons/app/userinput/discussion.json` is `app.userinput.discussion`, and +so on for the rest — fetched from the lexicon authority +`did:plc:uyixj57k6nmxrdj7pjs2ss5s` (userinput.app's own PDS) rather than +hand-read from its minified web client. + +This authority *does* publish `com.atproto.lexicon.schema` records, so the +`userinput` entry in `../lexicons.json`'s `sources` names the authority DID, +and each NSID under `lexicons` carries its own at://-uri and CID — a copy +here is provably what the authority published, not a guess. + +Refresh with `../scripts/vendor-userinput-lexicons.sh`, which re-resolves +every NSID and rewrites both the JSON here and the generated Rust in +`../vendor/userinput-lexicon/`. + +Only `discussion` is turned into Rust today (`vendor/userinput-lexicon/`), +because it is the one record `atgc report` writes and the one with limits +worth enforcing before a write (title, body, tag count). The other 15 are +vendored for the same reason headquarters vendors `blue.lance.camo`: so the +shape a client assumes is checked against what the authority actually +publishes, not carried only in a comment. See `TODO.md` for what would need +generating next (`upvote`, `#image` for attachments) if `report` grows to use +them. + +## Both -Scoped to exactly the seven lexicons above — not Tangled's full surface -(issues, CI, knot management, and every XRPC endpoint atgc never calls). Widen -`scripts/vendor-tangled-lexicons.sh`'s codegen input if a future command needs -another one typed. +A normal `cargo build` reads neither the network nor either vendor script — +everything both need is already committed. Each script only touches its own +`source` entry and its own NSIDs under `lexicons` in `../lexicons.json`, so +running one never disturbs the other's pins. diff --git a/lexicons/app/userinput/authBasic.json b/lexicons/app/userinput/authBasic.json new file mode 100644 index 0000000..4afab40 --- /dev/null +++ b/lexicons/app/userinput/authBasic.json @@ -0,0 +1,24 @@ +{ + "id": "app.userinput.authBasic", + "defs": { + "main": { + "type": "permission-set", + "title": "user input", + "detail": "Start discussions, reply, vote, and edit your own posts.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "app.userinput.discussion", + "app.userinput.reply", + "app.userinput.upvote", + "app.userinput.downvote", + "app.userinput.edit" + ] + } + ] + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/authFull.json b/lexicons/app/userinput/authFull.json new file mode 100644 index 0000000..e24ba18 --- /dev/null +++ b/lexicons/app/userinput/authFull.json @@ -0,0 +1,33 @@ +{ + "id": "app.userinput.authFull", + "defs": { + "main": { + "type": "permission-set", + "title": "user input", + "detail": "Create discussions and replies, vote, and manage your spaces and moderation.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "app.userinput.space", + "app.userinput.discussion", + "app.userinput.reply", + "app.userinput.upvote", + "app.userinput.downvote", + "app.userinput.status", + "app.userinput.member", + "app.userinput.pin", + "app.userinput.hide", + "app.userinput.ban", + "app.userinput.checkedNotifications", + "app.userinput.visited", + "app.userinput.lock", + "app.userinput.edit" + ] + } + ] + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/ban.json b/lexicons/app/userinput/ban.json new file mode 100644 index 0000000..c96be0a --- /dev/null +++ b/lexicons/app/userinput/ban.json @@ -0,0 +1,35 @@ +{ + "id": "app.userinput.ban", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "space", + "subject", + "createdAt" + ], + "properties": { + "space": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The space this ban applies to. Indexed by constellation at space.uri." + }, + "subject": { + "type": "string", + "format": "did", + "description": "The DID being banned from the space." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "Bans a user from a space (their content is hidden and they cannot post). Only honored when authored by the space owner or a moderator. Lives in the moderator's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/checkedNotifications.json b/lexicons/app/userinput/checkedNotifications.json new file mode 100644 index 0000000..9c1b9c6 --- /dev/null +++ b/lexicons/app/userinput/checkedNotifications.json @@ -0,0 +1,23 @@ +{ + "id": "app.userinput.checkedNotifications", + "defs": { + "main": { + "key": "literal:self", + "type": "record", + "record": { + "type": "object", + "required": [ + "checkedAt" + ], + "properties": { + "checkedAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "Singleton (rkey 'self') marking when the user last checked their notifications." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/discussion.json b/lexicons/app/userinput/discussion.json new file mode 100644 index 0000000..94b66c6 --- /dev/null +++ b/lexicons/app/userinput/discussion.json @@ -0,0 +1,80 @@ +{ + "id": "app.userinput.discussion", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "space", + "title", + "createdAt" + ], + "properties": { + "body": { + "type": "string", + "maxLength": 20000, + "maxGraphemes": 10000 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64 + }, + "maxLength": 8, + "description": "Tag values drawn from the space's tag list." + }, + "space": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "Strong reference to the app.userinput.space this belongs to. Indexed by constellation at space.uri." + }, + "title": { + "type": "string", + "maxLength": 600, + "maxGraphemes": 300 + }, + "images": { + "type": "array", + "items": { + "ref": "#image", + "type": "ref" + }, + "maxLength": 4, + "description": "Optional image attachments." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "A piece of feedback posted into a space. Lives in the author's repo and points at the space." + }, + "image": { + "type": "object", + "required": [ + "image" + ], + "properties": { + "alt": { + "type": "string", + "maxLength": 2000 + }, + "image": { + "type": "blob", + "accept": [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif" + ], + "maxSize": 1000000 + } + } + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/downvote.json b/lexicons/app/userinput/downvote.json new file mode 100644 index 0000000..5e96c05 --- /dev/null +++ b/lexicons/app/userinput/downvote.json @@ -0,0 +1,29 @@ +{ + "id": "app.userinput.downvote", + "defs": { + "main": { + "key": "any", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion or reply being voted on. Indexed by constellation at subject.uri." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "A downvote on a discussion or reply. Written at the SAME record key as its subject, so a user holds at most one per subject. Lives in the voter's repo; counted network-wide by constellation at subject.uri." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/edit.json b/lexicons/app/userinput/edit.json new file mode 100644 index 0000000..dae4414 --- /dev/null +++ b/lexicons/app/userinput/edit.json @@ -0,0 +1,50 @@ +{ + "id": "app.userinput.edit", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "body": { + "type": "string", + "maxLength": 20000, + "description": "Replacement body.", + "maxGraphemes": 10000 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64 + }, + "maxLength": 8, + "description": "Replacement tag values (discussion edits only), drawn from the space's tag list." + }, + "title": { + "type": "string", + "maxLength": 600, + "description": "Replacement title (discussion edits only).", + "maxGraphemes": 300 + }, + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion or reply being revised. Indexed by constellation at subject.uri. The pinned cid identifies the version this edit was authored against." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "A revision of a discussion or reply, written as a sidecar that points at the original (never mutating it). Only honored when authored by the subject's own author; the newest createdAt is the live content, and the full set is the edit history. Lives in the author's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/hide.json b/lexicons/app/userinput/hide.json new file mode 100644 index 0000000..356e35b --- /dev/null +++ b/lexicons/app/userinput/hide.json @@ -0,0 +1,35 @@ +{ + "id": "app.userinput.hide", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "space", + "subject", + "createdAt" + ], + "properties": { + "space": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The space this moderation applies to. Indexed by constellation at space.uri." + }, + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion or reply being hidden." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "Hides a discussion or reply within a space. Only honored when authored by the space owner or a moderator. Lives in the moderator's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/lock.json b/lexicons/app/userinput/lock.json new file mode 100644 index 0000000..0d49792 --- /dev/null +++ b/lexicons/app/userinput/lock.json @@ -0,0 +1,35 @@ +{ + "id": "app.userinput.lock", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "lockedAt", + "createdAt" + ], + "properties": { + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion being locked. Indexed by constellation at subject.uri." + }, + "lockedAt": { + "type": "string", + "format": "datetime", + "description": "Cutoff time: replies with createdAt at or before this are shown; later ones are hidden." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "A moderation lock on a discussion: replies created after lockedAt are no longer shown, and the thread is closed to new replies. Only honored when authored by the space owner or an appointed moderator. Latest createdAt wins; deleting the record unlocks (for that moderator). Lives in the moderator's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/member.json b/lexicons/app/userinput/member.json new file mode 100644 index 0000000..decac33 --- /dev/null +++ b/lexicons/app/userinput/member.json @@ -0,0 +1,43 @@ +{ + "id": "app.userinput.member", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "space", + "subject", + "role", + "createdAt" + ], + "properties": { + "role": { + "enum": [ + "moderator", + "admin" + ], + "type": "string" + }, + "space": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The space this membership applies to. Indexed by constellation at space.uri." + }, + "subject": { + "type": "string", + "format": "did", + "description": "The DID being granted a role." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "Grants a DID a role on a space. Only honored when authored by the space owner. Lives in the owner's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/pin.json b/lexicons/app/userinput/pin.json new file mode 100644 index 0000000..3527fce --- /dev/null +++ b/lexicons/app/userinput/pin.json @@ -0,0 +1,35 @@ +{ + "id": "app.userinput.pin", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "space", + "subject", + "createdAt" + ], + "properties": { + "space": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The space the pin applies to. Indexed by constellation at space.uri." + }, + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion being pinned." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "Pins a discussion to the top of a space. Only honored when authored by the space owner or a moderator. Lives in the moderator's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/reply.json b/lexicons/app/userinput/reply.json new file mode 100644 index 0000000..d85ff55 --- /dev/null +++ b/lexicons/app/userinput/reply.json @@ -0,0 +1,40 @@ +{ + "id": "app.userinput.reply", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "body", + "createdAt" + ], + "properties": { + "body": { + "type": "string", + "maxLength": 20000, + "maxGraphemes": 10000 + }, + "parent": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "Optional parent reply, for threading." + }, + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion being replied to. Indexed by constellation at subject.uri." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "A reply on a discussion. Lives in the author's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/space.json b/lexicons/app/userinput/space.json new file mode 100644 index 0000000..b60fecf --- /dev/null +++ b/lexicons/app/userinput/space.json @@ -0,0 +1,72 @@ +{ + "id": "app.userinput.space", + "defs": { + "tag": { + "type": "object", + "required": [ + "value", + "label" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 64, + "description": "Human-readable display label." + }, + "value": { + "type": "string", + "maxLength": 64, + "description": "Stable machine value stored on discussions." + } + } + }, + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "name", + "createdAt" + ], + "properties": { + "icon": { + "type": "blob", + "accept": [ + "image/png", + "image/jpeg", + "image/webp" + ], + "maxSize": 1000000, + "description": "Optional space icon/logo." + }, + "name": { + "type": "string", + "maxLength": 200, + "maxGraphemes": 100 + }, + "tags": { + "type": "array", + "items": { + "ref": "#tag", + "type": "ref" + }, + "maxLength": 24, + "description": "The set of categories feedback in this space may be filed under." + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "description": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 1500 + } + } + }, + "description": "A feedback space created by an organization. Lives in the owner's repo; the owner DID is the repo it is stored in." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/status.json b/lexicons/app/userinput/status.json new file mode 100644 index 0000000..6c3a60d --- /dev/null +++ b/lexicons/app/userinput/status.json @@ -0,0 +1,54 @@ +{ + "id": "app.userinput.status", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "state", + "createdAt" + ], + "properties": { + "note": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 1500 + }, + "state": { + "enum": [ + "open", + "under-review", + "backlog", + "planned", + "in-progress", + "implemented", + "declined", + "duplicate", + "closed" + ], + "type": "string" + }, + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion this status applies to. Indexed by constellation at subject.uri." + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "duplicateOf": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "When state is 'duplicate', the canonical discussion this duplicates." + } + } + }, + "description": "An official status assigned to a discussion. Only honored when authored by the space owner or an appointed moderator. Latest createdAt wins. Lives in the moderator's repo." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/upvote.json b/lexicons/app/userinput/upvote.json new file mode 100644 index 0000000..77e50cd --- /dev/null +++ b/lexicons/app/userinput/upvote.json @@ -0,0 +1,29 @@ +{ + "id": "app.userinput.upvote", + "defs": { + "main": { + "key": "any", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The discussion or reply being voted on. Indexed by constellation at subject.uri." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "An upvote on a discussion or reply. Written at the SAME record key as its subject, so a user holds at most one per subject. Lives in the voter's repo; counted network-wide by constellation at subject.uri." + } + }, + "lexicon": 1 +} diff --git a/lexicons/app/userinput/visited.json b/lexicons/app/userinput/visited.json new file mode 100644 index 0000000..dc9ebeb --- /dev/null +++ b/lexicons/app/userinput/visited.json @@ -0,0 +1,36 @@ +{ + "id": "app.userinput.visited", + "defs": { + "main": { + "key": "any", + "type": "record", + "record": { + "type": "object", + "required": [ + "space", + "firstVisited", + "lastVisited" + ], + "properties": { + "space": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "The visited space. Carries the full identity (owner DID + rkey + cid); the record key alone is only a dedup key." + }, + "lastVisited": { + "type": "string", + "format": "datetime", + "description": "When the author most recently visited this space. Bumped on every recorded visit; the 'boards you've visited' list sorts by this." + }, + "firstVisited": { + "type": "string", + "format": "datetime", + "description": "When the author first visited this space. Preserved across re-visits." + } + } + }, + "description": "A private-ish bookmark marking a space the author has visited, written to the author's own repo. The record key matches the visited space's record key, so re-visiting overwrites the same record (one per space). Used to populate the author's 'boards you've visited' list quickly, ordered by lastVisited." + } + }, + "lexicon": 1 +} diff --git a/scripts/vendor-userinput-lexicons.sh b/scripts/vendor-userinput-lexicons.sh new file mode 100755 index 0000000..5f8b1b4 --- /dev/null +++ b/scripts/vendor-userinput-lexicons.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Re-fetch every app.userinput.* lexicon schema from its published authority +# (did:plc:uyixj57k6nmxrdj7pjs2ss5s) into lexicons/app/userinput/, pin each +# one's at://-uri and CID in lexicons.json, and regenerate the Rust bindings +# under vendor/userinput-lexicon/. Run this by hand when the lexicon changes; +# a normal `cargo build` never touches the network and never re-runs this — +# the vendored JSON and the generated code are both committed. +# +# Needs `jacquard-codegen` on PATH: cargo install jacquard-lexgen --locked +set -euo pipefail +cd "$(dirname "$0")/.." + +if ! command -v jacquard-codegen >/dev/null; then + echo "jacquard-codegen not found; run: cargo install jacquard-lexgen --locked" >&2 + exit 1 +fi + +AUTHORITY_DID="did:plc:uyixj57k6nmxrdj7pjs2ss5s" +PDS=$(curl -sS "https://plc.directory/$AUTHORITY_DID" | + python3 -c "import json,sys; d=json.load(sys.stdin); print(next(s['serviceEndpoint'] for s in d['service'] if s['id']=='#atproto_pds'))") + +mkdir -p lexicons/app/userinput +curl -sS "$PDS/xrpc/com.atproto.repo.listRecords?repo=$AUTHORITY_DID&collection=com.atproto.lexicon.schema&limit=100" | + python3 -c " +import json, sys +d = json.load(sys.stdin) +manifest = {'version': 1, 'lexicons': [], 'resolutions': {}} +for r in sorted(d['records'], key=lambda r: r['value']['id']): + val = r['value'] + nsid = val['id'] + body = {k: v for k, v in val.items() if k != '\$type'} + with open('lexicons/app/userinput/' + nsid.rsplit('.', 1)[-1] + '.json', 'w') as f: + json.dump(body, f, indent=2) + f.write('\n') + manifest['lexicons'].append(nsid) + manifest['resolutions'][nsid] = {'uri': r['uri'], 'cid': r['cid']} +with open('lexicons.json', 'w') as f: + json.dump(manifest, f, indent=2) + f.write('\n') +" + +# Only app.userinput.discussion is generated today — the record atgc's +# `report` command writes and validates against. Widen this (and the crate's +# `[features]`) if a later feature needs another record typed, e.g. `upvote` +# or `#image` for attachments (TODO.md). +codegen_in=$(mktemp -d) +trap 'rm -rf "$codegen_in"' EXIT +cp lexicons/app/userinput/discussion.json "$codegen_in/app.userinput.discussion.json" +codegen_out=$(mktemp -d) +jacquard-codegen -i "$codegen_in" -o "$codegen_out" + +rm -rf vendor/userinput-lexicon/src/app_userinput +cp -r "$codegen_out/app_userinput" vendor/userinput-lexicon/src/app_userinput +cp "$codegen_out/builder_types.rs" vendor/userinput-lexicon/src/builder_types.rs +rm -rf "$codegen_out" + +# jacquard-codegen's "Pretty" mode (prettyplease) formats close to rustfmt but +# not identically; cargo fmt --all reaches this crate too, since it is a plain +# path dependency rather than a [patch] one (see docs/vendored-dependencies.md), +# so leaving its output unformatted would just fail the fmt hook on the next +# commit that happens to touch anything else. +cargo fmt --all + +echo "vendored $(python3 -c "import json; print(len(json.load(open('lexicons.json'))['lexicons']))") lexicons; regenerated vendor/userinput-lexicon/src/app_userinput/" +echo "lib.rs was not touched — it carries a hand-added allow attribute and a LexiconSchema re-export; diff it if codegen's crate-level output shape ever changes" diff --git a/vendor/userinput-lexicon/Cargo.toml b/vendor/userinput-lexicon/Cargo.toml new file mode 100644 index 0000000..e54e431 --- /dev/null +++ b/vendor/userinput-lexicon/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "userinput-lexicon" +version = "0.1.0" +edition = "2021" +publish = false + +# Generated bindings for the `app.userinput.*` lexicon, scoped to the one +# record atgc currently does anything with. See ../../lexicons/README.md for +# how these are vendored and regenerated. + +[lib] +path = "src/lib.rs" + +[features] +default = ["app_userinput"] +app_userinput = [] + +[dependencies] +jacquard-common = "0.12.1" +# codegen (the default feature) pulls in syn/quote/prettyplease/heck, which +# this crate only needs to *consume* generated code, not to generate it. +jacquard-lexicon = { version = "0.12.1", default-features = false } +jacquard-derive = "0.12.1" +serde = { version = "1", features = ["derive"] } diff --git a/vendor/userinput-lexicon/src/app_userinput.rs b/vendor/userinput-lexicon/src/app_userinput.rs new file mode 100644 index 0000000..b83f004 --- /dev/null +++ b/vendor/userinput-lexicon/src/app_userinput.rs @@ -0,0 +1,7 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated bindings for the `app.userinput` Lexicon namespace/module. +pub mod discussion; diff --git a/vendor/userinput-lexicon/src/app_userinput/discussion.rs b/vendor/userinput-lexicon/src/app_userinput/discussion.rs new file mode 100644 index 0000000..0bd8eb5 --- /dev/null +++ b/vendor/userinput-lexicon/src/app_userinput/discussion.rs @@ -0,0 +1,756 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: app.userinput.discussion +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{BosStr, CowStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::blob::BlobRef; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{AtUri, Cid, Datetime}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +use crate::app_userinput::discussion; +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct Image { + #[serde(skip_serializing_if = "Option::is_none")] + pub alt: Option, + pub image: BlobRef, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +/// A piece of feedback posted into a space. Lives in the author's repo and points at the space. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "app.userinput.discussion", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct Discussion { + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + pub created_at: Datetime, + ///Optional image attachments. + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>>, + ///Strong reference to the app.userinput.space this belongs to. Indexed by constellation at space.uri. + pub space: Data, + ///Tag values drawn from the space's tag list. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + pub title: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: AtUri, + pub value: Discussion, +} + +impl Discussion { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(AtUri::new(uri)?) + } +} + +impl LexiconSchema for Image { + fn nsid() -> &'static str { + "app.userinput.discussion" + } + fn def_name() -> &'static str { + "image" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_app_userinput_discussion() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.alt { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 2000usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("alt"), + max: 2000usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.image; + { + let size = value.blob().size; + if size > 1000000usize { + return Err(ConstraintError::BlobTooLarge { + path: ValidationPath::from_field("image"), + max: 1000000usize, + actual: size, + }); + } + } + } + { + let value = &self.image; + { + let mime = value.blob().mime_type.as_str(); + let accepted: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif"]; + let matched = accepted.iter().any(|pattern| { + if *pattern == "*/*" { + true + } else if pattern.ends_with("/*") { + let prefix = &pattern[..pattern.len() - 2]; + mime.starts_with(prefix) && mime.as_bytes().get(prefix.len()) == Some(&b'/') + } else { + mime == *pattern + } + }); + if !matched { + return Err(ConstraintError::BlobMimeTypeNotAccepted { + path: ValidationPath::from_field("image"), + accepted: vec![ + "image/png".to_string(), + "image/jpeg".to_string(), + "image/webp".to_string(), + "image/gif".to_string(), + ], + actual: mime.to_string(), + }); + } + } + } + Ok(()) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct DiscussionRecord; +impl XrpcResp for DiscussionRecord { + const NSID: &'static str = "app.userinput.discussion"; + const ENCODING: &'static str = "application/json"; + type Output = DiscussionGetRecordOutput; + type Err = RecordError; +} + +impl From> for Discussion { + fn from(output: DiscussionGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for Discussion { + const NSID: &'static str = "app.userinput.discussion"; + type Record = DiscussionRecord; +} + +impl Collection for DiscussionRecord { + const NSID: &'static str = "app.userinput.discussion"; + type Record = DiscussionRecord; +} + +impl LexiconSchema for Discussion { + fn nsid() -> &'static str { + "app.userinput.discussion" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_app_userinput_discussion() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.body { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 20000usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("body"), + max: 20000usize, + actual: ::len(value.as_ref()), + }); + } + } + if let Some(ref value) = self.body { + { + let count = UnicodeSegmentation::graphemes(value.as_ref(), true).count(); + if count > 10000usize { + return Err(ConstraintError::MaxGraphemes { + path: ValidationPath::from_field("body"), + max: 10000usize, + actual: count, + }); + } + } + } + if let Some(ref value) = self.images { + #[allow(unused_comparisons)] + if value.len() > 4usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("images"), + max: 4usize, + actual: value.len(), + }); + } + } + if let Some(ref value) = self.tags { + #[allow(unused_comparisons)] + if value.len() > 8usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("tags"), + max: 8usize, + actual: value.len(), + }); + } + } + { + let value = &self.title; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 600usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("title"), + max: 600usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.title; + { + let count = UnicodeSegmentation::graphemes(value.as_ref(), true).count(); + if count > 300usize { + return Err(ConstraintError::MaxGraphemes { + path: ValidationPath::from_field("title"), + max: 300usize, + actual: count, + }); + } + } + } + Ok(()) + } +} + +pub mod image_state { + + pub use crate::builder_types::{IsSet, IsUnset, Set, Unset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Image; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Image = Unset; + } + ///State transition - sets the `image` field to Set + pub struct SetImage(PhantomData St>); + impl sealed::Sealed for SetImage {} + impl State for SetImage { + type Image = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `image` field + pub struct image(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct ImageBuilder { + _state: PhantomData St>, + _fields: (Option, Option>), + _type: PhantomData S>, +} + +impl Image { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> ImageBuilder { + ImageBuilder::new() + } +} + +impl Image { + /// Create a new builder for this type + pub fn builder() -> ImageBuilder { + ImageBuilder::builder() + } +} + +impl ImageBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + ImageBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl ImageBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + ImageBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl ImageBuilder { + /// Set the `alt` field (optional) + pub fn alt(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `alt` field to an Option value (optional) + pub fn maybe_alt(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl ImageBuilder +where + St: image_state::State, + St::Image: image_state::IsUnset, +{ + /// Set the `image` field (required) + pub fn image( + mut self, + value: impl Into>, + ) -> ImageBuilder, S> { + self._fields.1 = Option::Some(value.into()); + ImageBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl ImageBuilder +where + St: image_state::State, + St::Image: image_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Image { + Image { + alt: self._fields.0, + image: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Image { + Image { + alt: self._fields.0, + image: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_app_userinput_discussion() -> LexiconDoc<'static> { + use alloc::collections::BTreeMap; + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("app.userinput.discussion"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("image"), + LexUserType::Object(LexObject { + required: Some(vec![SmolStr::new_static("image")]), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("alt"), + LexObjectProperty::String(LexString { + max_length: Some(2000usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("image"), + LexObjectProperty::Blob(LexBlob { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static( + "A piece of feedback posted into a space. Lives in the author's repo and points at the space.", + ), + ), + key: Some(CowStr::new_static("tid")), + record: LexRecordRecord::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("space"), SmolStr::new_static("title"), + SmolStr::new_static("createdAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("body"), + LexObjectProperty::String(LexString { + max_length: Some(20000usize), + max_graphemes: Some(10000usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("images"), + LexObjectProperty::Array(LexArray { + description: Some( + CowStr::new_static("Optional image attachments."), + ), + items: LexArrayItem::Ref(LexRef { + r#ref: CowStr::new_static("#image"), + ..Default::default() + }), + max_length: Some(4usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("space"), + LexObjectProperty::Ref(LexRef { + r#ref: CowStr::new_static("com.atproto.repo.strongRef"), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("tags"), + LexObjectProperty::Array(LexArray { + description: Some( + CowStr::new_static( + "Tag values drawn from the space's tag list.", + ), + ), + items: LexArrayItem::String(LexString { + max_length: Some(64usize), + ..Default::default() + }), + max_length: Some(8usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("title"), + LexObjectProperty::String(LexString { + max_length: Some(600usize), + max_graphemes: Some(300usize), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +pub mod discussion_state { + + pub use crate::builder_types::{IsSet, IsUnset, Set, Unset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Space; + type Title; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Space = Unset; + type Title = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Space = St::Space; + type Title = St::Title; + } + ///State transition - sets the `space` field to Set + pub struct SetSpace(PhantomData St>); + impl sealed::Sealed for SetSpace {} + impl State for SetSpace { + type CreatedAt = St::CreatedAt; + type Space = Set; + type Title = St::Title; + } + ///State transition - sets the `title` field to Set + pub struct SetTitle(PhantomData St>); + impl sealed::Sealed for SetTitle {} + impl State for SetTitle { + type CreatedAt = St::CreatedAt; + type Space = St::Space; + type Title = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `space` field + pub struct space(()); + ///Marker type for the `title` field + pub struct title(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DiscussionBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option, + Option>>, + Option>, + Option>, + Option, + ), + _type: PhantomData S>, +} + +impl Discussion { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DiscussionBuilder { + DiscussionBuilder::new() + } +} + +impl Discussion { + /// Create a new builder for this type + pub fn builder() -> DiscussionBuilder { + DiscussionBuilder::builder() + } +} + +impl DiscussionBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DiscussionBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl DiscussionBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DiscussionBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl DiscussionBuilder { + /// Set the `body` field (optional) + pub fn body(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `body` field to an Option value (optional) + pub fn maybe_body(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl DiscussionBuilder +where + St: discussion_state::State, + St::CreatedAt: discussion_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> DiscussionBuilder, S> { + self._fields.1 = Option::Some(value.into()); + DiscussionBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DiscussionBuilder { + /// Set the `images` field (optional) + pub fn images(mut self, value: impl Into>>>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `images` field to an Option value (optional) + pub fn maybe_images(mut self, value: Option>>) -> Self { + self._fields.2 = value; + self + } +} + +impl DiscussionBuilder +where + St: discussion_state::State, + St::Space: discussion_state::IsUnset, +{ + /// Set the `space` field (required) + pub fn space( + mut self, + value: impl Into>, + ) -> DiscussionBuilder, S> { + self._fields.3 = Option::Some(value.into()); + DiscussionBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DiscussionBuilder { + /// Set the `tags` field (optional) + pub fn tags(mut self, value: impl Into>>) -> Self { + self._fields.4 = value.into(); + self + } + /// Set the `tags` field to an Option value (optional) + pub fn maybe_tags(mut self, value: Option>) -> Self { + self._fields.4 = value; + self + } +} + +impl DiscussionBuilder +where + St: discussion_state::State, + St::Title: discussion_state::IsUnset, +{ + /// Set the `title` field (required) + pub fn title( + mut self, + value: impl Into, + ) -> DiscussionBuilder, S> { + self._fields.5 = Option::Some(value.into()); + DiscussionBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DiscussionBuilder +where + St: discussion_state::State, + St::CreatedAt: discussion_state::IsSet, + St::Space: discussion_state::IsSet, + St::Title: discussion_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Discussion { + Discussion { + body: self._fields.0, + created_at: self._fields.1.unwrap(), + images: self._fields.2, + space: self._fields.3.unwrap(), + tags: self._fields.4, + title: self._fields.5.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Discussion { + Discussion { + body: self._fields.0, + created_at: self._fields.1.unwrap(), + images: self._fields.2, + space: self._fields.3.unwrap(), + tags: self._fields.4, + title: self._fields.5.unwrap(), + extra_data: Some(extra_data), + } + } +} diff --git a/vendor/userinput-lexicon/src/builder_types.rs b/vendor/userinput-lexicon/src/builder_types.rs new file mode 100644 index 0000000..dea82dc --- /dev/null +++ b/vendor/userinput-lexicon/src/builder_types.rs @@ -0,0 +1,45 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +/// Marker type indicating a builder field has been set +pub struct Set(pub T); +impl Set { + /// Extract the inner value + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + +/// Marker type indicating a builder field has not been set +pub struct Unset; +/// Trait indicating a builder field is set (has a value) + +#[jacquard_common::deps::codegen::rustversion::attr( + since(1.78.0), + diagnostic::on_unimplemented( + message = "the field `{Self}` was not set, but this method requires it to be set", + label = "the field `{Self}` was not set" + ) +)] +pub trait IsSet: private::Sealed {} +/// Trait indicating a builder field is unset (no value yet) + +#[jacquard_common::deps::codegen::rustversion::attr( + since(1.78.0), + diagnostic::on_unimplemented( + message = "the field `{Self}` was already set, but this method requires it to be unset", + label = "the field `{Self}` was already set" + ) +)] +pub trait IsUnset: private::Sealed {} +impl IsSet for Set {} +impl IsUnset for Unset {} +mod private { + /// Sealed trait to prevent external implementations + pub trait Sealed {} + impl Sealed for super::Set {} + impl Sealed for super::Unset {} +} diff --git a/vendor/userinput-lexicon/src/lib.rs b/vendor/userinput-lexicon/src/lib.rs new file mode 100644 index 0000000..f45088e --- /dev/null +++ b/vendor/userinput-lexicon/src/lib.rs @@ -0,0 +1,58 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated AT Protocol Lexicon bindings. +//! +//! This crate contains Rust bindings generated from Lexicon schemas. Most +//! application code uses these types through the `jacquard::api` re-export, +//! alongside `jacquard::Client` or one of the higher-level session helpers. +//! +//! ## Finding things +//! +//! Lexicon NSIDs map to Rust modules by replacing dots with module separators +//! and using snake_case for Rust identifiers. For example, +//! `app.bsky.feed.getTimeline` is generated under +//! `app_bsky::feed::get_timeline`. +//! +//! Top-level namespace modules are feature-gated. Enable the feature matching +//! the top-level namespace module, such as `app_bsky` or `com_atproto`, to use +//! those bindings. +//! +//! ## String backing +//! +//! Many generated data types are generic over a string backing type, +//! usually written as `S: BosStr = DefaultStr`. The generic is part of the +//! public API: use the default owned backing when you do not need to choose, +//! or select another backing such as `String`, `&str`, or `CowStr<'_>` when +//! that is useful for your code. +//! +//! Common examples omit the generic because the default is inferred in the +//! usual request/response paths. +//! +//! ## XRPC endpoints +//! +//! Endpoint modules contain request parameter/input structs, output structs, +//! error enums, and marker types implementing +//! `jacquard_common::xrpc::{XrpcRequest, XrpcResp, XrpcEndpoint}`. High-level +//! client code normally constructs the request struct or marker type and sends +//! it with `jacquard::Client`, then uses response helpers such as +//! `into_output()` for default-backed output. +//! +// Codegen emits imports some generated files don't end up needing — real +// across the full corpus too, not an artifact of this crate's one-lexicon +// scope. Suppressed here rather than hand-edited into files marked +// DO NOT EDIT. +#![allow(unused_imports)] +extern crate alloc; + +#[cfg(feature = "app_userinput")] +pub mod app_userinput; +pub mod builder_types; + +// Hand-added, not generated: regeneration only touches app_userinput/ and +// builder_types.rs (see ../../scripts/vendor-userinput-lexicons.sh), so this +// re-export survives it. Lets a consumer call `.validate()` without taking a +// direct dependency on jacquard-lexicon itself. +pub use jacquard_lexicon::schema::LexiconSchema;