+22
-19
crates/core/src/at_uri.rs
+22
-19
crates/core/src/at_uri.rs
···
4
4
//! Format: `at://<authority>/<collection>/<rkey>`
5
5
//!
6
6
//! - authority: DID or handle
7
-
//! - collection: NSID (e.g., "app.malfestio.deck")
7
+
//! - collection: NSID (e.g., "org.stormlightlabs.malfestio.deck")
8
8
//! - rkey: Record key (usually a TID)
9
9
10
10
use std::fmt;
···
14
14
pub struct AtUri {
15
15
/// The authority (DID or handle)
16
16
pub authority: String,
17
-
/// The collection NSID (e.g., "app.malfestio.deck")
17
+
/// The collection NSID (e.g., "org.stormlightlabs.malfestio.deck")
18
18
pub collection: String,
19
19
/// The record key
20
20
pub rkey: String,
···
34
34
35
35
/// Create an AT-URI for a deck record.
36
36
pub fn deck(did: &str, rkey: &str) -> Self {
37
-
Self::new(did, "app.malfestio.deck", rkey)
37
+
Self::new(did, "org.stormlightlabs.malfestio.deck", rkey)
38
38
}
39
39
40
40
/// Create an AT-URI for a card record.
41
41
pub fn card(did: &str, rkey: &str) -> Self {
42
-
Self::new(did, "app.malfestio.card", rkey)
42
+
Self::new(did, "org.stormlightlabs.malfestio.card", rkey)
43
43
}
44
44
45
45
/// Create an AT-URI for a note record.
46
46
pub fn note(did: &str, rkey: &str) -> Self {
47
-
Self::new(did, "app.malfestio.note", rkey)
47
+
Self::new(did, "org.stormlightlabs.malfestio.note", rkey)
48
48
}
49
49
50
50
/// Parse an AT-URI string.
···
126
126
127
127
#[test]
128
128
fn test_new_at_uri() {
129
-
let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123");
129
+
let uri = AtUri::new("did:plc:abc123", "org.stormlightlabs.malfestio.deck", "3k5abc123");
130
130
assert_eq!(uri.authority, "did:plc:abc123");
131
-
assert_eq!(uri.collection, "app.malfestio.deck");
131
+
assert_eq!(uri.collection, "org.stormlightlabs.malfestio.deck");
132
132
assert_eq!(uri.rkey, "3k5abc123");
133
133
}
134
134
135
135
#[test]
136
136
fn test_display() {
137
-
let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123");
138
-
assert_eq!(uri.to_string(), "at://did:plc:abc123/app.malfestio.deck/3k5abc123");
137
+
let uri = AtUri::new("did:plc:abc123", "org.stormlightlabs.malfestio.deck", "3k5abc123");
138
+
assert_eq!(
139
+
uri.to_string(),
140
+
"at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123"
141
+
);
139
142
}
140
143
141
144
#[test]
142
145
fn test_parse_valid() {
143
-
let uri = AtUri::parse("at://did:plc:abc123/app.malfestio.deck/3k5abc123").unwrap();
146
+
let uri = AtUri::parse("at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123").unwrap();
144
147
assert_eq!(uri.authority, "did:plc:abc123");
145
-
assert_eq!(uri.collection, "app.malfestio.deck");
148
+
assert_eq!(uri.collection, "org.stormlightlabs.malfestio.deck");
146
149
assert_eq!(uri.rkey, "3k5abc123");
147
150
}
148
151
149
152
#[test]
150
153
fn test_parse_with_handle() {
151
-
let uri = AtUri::parse("at://alice.bsky.social/app.malfestio.note/abc123").unwrap();
154
+
let uri = AtUri::parse("at://alice.bsky.social/org.stormlightlabs.malfestio.note/abc123").unwrap();
152
155
assert_eq!(uri.authority, "alice.bsky.social");
153
156
assert!(uri.is_handle());
154
157
assert!(!uri.is_did());
···
156
159
157
160
#[test]
158
161
fn test_parse_missing_scheme() {
159
-
let result = AtUri::parse("did:plc:abc123/app.malfestio.deck/3k5abc123");
162
+
let result = AtUri::parse("did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123");
160
163
assert_eq!(result, Err(AtUriError::MissingScheme));
161
164
}
162
165
163
166
#[test]
164
167
fn test_parse_invalid_format() {
165
-
let result = AtUri::parse("at://did:plc:abc123/app.malfestio.deck");
168
+
let result = AtUri::parse("at://did:plc:abc123/org.stormlightlabs.malfestio.deck");
166
169
assert_eq!(result, Err(AtUriError::InvalidFormat));
167
170
}
168
171
169
172
#[test]
170
173
fn test_parse_empty_authority() {
171
-
let result = AtUri::parse("at:///app.malfestio.deck/rkey");
174
+
let result = AtUri::parse("at:///org.stormlightlabs.malfestio.deck/rkey");
172
175
assert_eq!(result, Err(AtUriError::EmptyAuthority));
173
176
}
174
177
···
180
183
181
184
#[test]
182
185
fn test_roundtrip() {
183
-
let original = "at://did:plc:abc123/app.malfestio.deck/3k5abc123";
186
+
let original = "at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123";
184
187
let uri = AtUri::parse(original).unwrap();
185
188
assert_eq!(uri.to_string(), original);
186
189
}
···
188
191
#[test]
189
192
fn test_convenience_constructors() {
190
193
let deck = AtUri::deck("did:plc:abc", "tid123");
191
-
assert_eq!(deck.collection, "app.malfestio.deck");
194
+
assert_eq!(deck.collection, "org.stormlightlabs.malfestio.deck");
192
195
193
196
let card = AtUri::card("did:plc:abc", "tid456");
194
-
assert_eq!(card.collection, "app.malfestio.card");
197
+
assert_eq!(card.collection, "org.stormlightlabs.malfestio.card");
195
198
196
199
let note = AtUri::note("did:plc:abc", "tid789");
197
-
assert_eq!(note.collection, "app.malfestio.note");
200
+
assert_eq!(note.collection, "org.stormlightlabs.malfestio.note");
198
201
}
199
202
200
203
#[test]
+14
-7
crates/server/src/repository/card.rs
+14
-7
crates/server/src/repository/card.rs
···
23
23
#[async_trait]
24
24
pub trait CardRepository: Send + Sync {
25
25
async fn create(&self, params: CreateCardParams) -> Result<Card, CardRepoError>;
26
-
27
26
async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError>;
28
-
29
27
async fn verify_deck_ownership(&self, deck_id: &str, owner_did: &str) -> Result<bool, CardRepoError>;
30
-
31
28
async fn update_at_uri(&self, card_id: &str, at_uri: &str) -> Result<(), CardRepoError>;
32
29
}
33
30
···
67
64
}
68
65
69
66
let card_id = uuid::Uuid::new_v4();
67
+
let card_type_str = match params.card_type {
68
+
CardType::Basic => "basic",
69
+
CardType::Cloze => "cloze",
70
+
};
70
71
client
71
72
.execute(
72
-
"INSERT INTO cards (id, owner_did, deck_id, front, back, media_url, hints)
73
-
VALUES ($1, $2, $3, $4, $5, $6, $7)",
73
+
"INSERT INTO cards (id, owner_did, deck_id, front, back, media_url, hints, card_type)
74
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
74
75
&[
75
76
&card_id,
76
77
¶ms.owner_did,
···
79
80
¶ms.back,
80
81
¶ms.media_url,
81
82
¶ms.hints,
83
+
&card_type_str,
82
84
],
83
85
)
84
86
.await
···
118
120
119
121
let rows = client
120
122
.query(
121
-
"SELECT id, owner_did, deck_id, front, back, media_url, hints
123
+
"SELECT id, owner_did, deck_id, front, back, media_url, hints, card_type
122
124
FROM cards
123
125
WHERE deck_id = $1
124
126
ORDER BY created_at ASC",
···
131
133
for row in rows {
132
134
let id: uuid::Uuid = row.get("id");
133
135
let card_deck_id: uuid::Uuid = row.get("deck_id");
136
+
let card_type_str: Option<String> = row.get("card_type");
137
+
let card_type = match card_type_str.as_deref() {
138
+
Some("cloze") => CardType::Cloze,
139
+
_ => CardType::Basic,
140
+
};
134
141
135
142
cards.push(Card {
136
143
id: id.to_string(),
···
139
146
front: row.get("front"),
140
147
back: row.get("back"),
141
148
media_url: row.get("media_url"),
142
-
card_type: CardType::default(),
149
+
card_type,
143
150
hints: row.get("hints"),
144
151
});
145
152
}
+1
-1
docs/at-notes.md
+1
-1
docs/at-notes.md
+14
-14
docs/data-model-mapping.md
+14
-14
docs/data-model-mapping.md
···
13
13
14
14
## Mapping Table
15
15
16
-
| Public Lexicon | Internal DB Table(s) | Notes |
17
-
| :----------------------------- | :------------------- | :------------------------------------------------------------- |
18
-
| `app.malfestio.deck` | `decks` | Public metadata. |
19
-
| `app.malfestio.card` | `cards` | Content. |
20
-
| `app.malfestio.note` | `notes` | Standalone notes. |
21
-
| `app.malfestio.source.*` | `sources` | Metadata for articles/lectures. |
22
-
| `app.malfestio.collection` | `collections` | |
23
-
| `app.malfestio.thread.comment` | `comments` | |
24
-
| _(None)_ | `reviews` | **Private**. Logs of every review event. |
25
-
| _(None)_ | `study_progress` | **Private**. Current SRS state for a card (box/interval/ease). |
26
-
| _(None)_ | `user_settings` | **Private**. Daily goals, UI references. |
27
-
| _(None)_ | `drafts` | **Private**. Content being authored before publishing. |
16
+
| Public Lexicon | Internal DB Table(s) | Notes |
17
+
| :-------------------------------------------- | :------------------- | :------------------------------------------------------------- |
18
+
| `org.stormlightlabs.malfestio.deck` | `decks` | Public metadata. |
19
+
| `org.stormlightlabs.malfestio.card` | `cards` | Content. |
20
+
| `org.stormlightlabs.malfestio.note` | `notes` | Standalone notes. |
21
+
| `org.stormlightlabs.malfestio.source.*` | `sources` | Metadata for articles/lectures. |
22
+
| `org.stormlightlabs.malfestio.collection` | `collections` | |
23
+
| `org.stormlightlabs.malfestio.thread.comment` | `comments` | |
24
+
| _(None)_ | `reviews` | **Private**. Logs of every review event. |
25
+
| _(None)_ | `study_progress` | **Private**. Current SRS state for a card (box/interval/ease). |
26
+
| _(None)_ | `user_settings` | **Private**. Daily goals, UI references. |
27
+
| _(None)_ | `drafts` | **Private**. Content being authored before publishing. |
28
28
29
29
## Sync Strategy
30
30
31
31
- **Publishing**:
32
-
Write to `drafts` -> User clicks "Publish" -> Sign record -> Push to PDS -> Move `drafts` content to `decks`/`cards` tables (or mark as synced).
32
+
Write to `drafts` -> User clicks "Publish" -> Sign record -> Push to PDS -> Move `drafts` content to `decks`/`cards` tables (or mark as synced).
33
33
- **Consuming**:
34
-
Pull from PDS (firehose or direct sync) -> Validate signature -> Upsert into local tables.
34
+
Pull from PDS (firehose or direct sync) -> Validate signature -> Upsert into local tables.
+10
-10
docs/information-architecture.md
+10
-10
docs/information-architecture.md
···
44
44
45
45
Mapping screens to underlying data entities (Lexicon Records + Private State).
46
46
47
-
| Screen / Component | Primary Data Entity | Secondary Entities | Private/Public |
48
-
| :----------------- | :----------------------------- | :---------------------------------------- | :---------------------------- |
49
-
| **Deck Overview** | `app.malfestio.deck` | `app.malfestio.card` (refs), User Profile | **Public** |
50
-
| **Study Session** | N/A (Ephemeral) | `app.malfestio.card`, Private Review Log | **Private** |
51
-
| **Card View** | `app.malfestio.card` | `app.malfestio.note`, Media Blobs | **Public** |
52
-
| **Editor** | Draft State (Local) | Source (`article`), `note` | **Private (Draft) -> Public** |
53
-
| **Source View** | `app.malfestio.source.article` | `app.malfestio.note` (linked) | **Public** |
54
-
| **Note View** | `app.malfestio.note` | Backlinks (`card`/`deck`) | **Public** |
55
-
| **Library** | `app.malfestio.collection` | Bookmarks, User Prefs | **Mixed** |
56
-
| **Comments** | `app.malfestio.thread.comment` | User Profile | **Public** |
47
+
| Screen / Component | Primary Data Entity | Secondary Entities | Private/Public |
48
+
| :----------------- | :-------------------------------------------- | :------------------------------------------------------- | :---------------------------- |
49
+
| **Deck Overview** | `org.stormlightlabs.malfestio.deck` | `org.stormlightlabs.malfestio.card` (refs), User Profile | **Public** |
50
+
| **Study Session** | N/A (Ephemeral) | `org.stormlightlabs.malfestio.card`, Private Review Log | **Private** |
51
+
| **Card View** | `org.stormlightlabs.malfestio.card` | `org.stormlightlabs.malfestio.note`, Media Blobs | **Public** |
52
+
| **Editor** | Draft State (Local) | Source (`article`), `note` | **Private (Draft) -> Public** |
53
+
| **Source View** | `org.stormlightlabs.malfestio.source.article` | `org.stormlightlabs.malfestio.note` (linked) | **Public** |
54
+
| **Note View** | `org.stormlightlabs.malfestio.note` | Backlinks (`card`/`deck`) | **Public** |
55
+
| **Library** | `org.stormlightlabs.malfestio.collection` | Bookmarks, User Prefs | **Mixed** |
56
+
| **Comments** | `org.stormlightlabs.malfestio.thread.comment` | User Profile | **Public** |
57
57
58
58
## URL Structure
59
59
+1
-1
docs/todo.md
+1
-1
docs/todo.md
···
131
131
132
132
**Indexing:**
133
133
134
-
- [ ] Subscribe to `com.atproto.sync.subscribeRepos` (or Jetstream) for `app.malfestio.*` records
134
+
- [ ] Subscribe to `com.atproto.sync.subscribeRepos` (or Jetstream) for `org.stormlightlabs.malfestio.*` records
135
135
- [ ] Index posts with compound cursor (timestamp::CID) for deterministic pagination
136
136
- [ ] Garbage collect indexed data older than 48 hours (except pinned content)
137
137
+8
-8
lexicons/README.md
+8
-8
lexicons/README.md
···
11
11
12
12
### Namespace + NSID conventions
13
13
14
-
- `app.malfestio.note`
15
-
- `app.malfestio.card`
16
-
- `app.malfestio.deck`
17
-
- `app.malfestio.source.article`
18
-
- `app.malfestio.source.lecture`
19
-
- `app.malfestio.collection`
20
-
- `app.malfestio.thread.comment`
14
+
- `org.stormlightlabs.malfestio.note`
15
+
- `org.stormlightlabs.malfestio.card`
16
+
- `org.stormlightlabs.malfestio.deck`
17
+
- `org.stormlightlabs.malfestio.source.article`
18
+
- `org.stormlightlabs.malfestio.source.lecture`
19
+
- `org.stormlightlabs.malfestio.collection`
20
+
- `org.stormlightlabs.malfestio.thread.comment`
21
21
22
22
### Lexicon basics
23
23
···
38
38
2. **No Renaming**: Do not rename fields.
39
39
If a semantic change is needed, add a new field and deprecate the old one.
40
40
3. **No Type Changes**: Once published, a field's type is fixed.
41
-
4. **Version by Copying**: If a breaking change is absolutely required, create a new Lexicon with a new major version or a new name (e.g., `app.malfestio.noteV2`).
41
+
4. **Version by Copying**: If a breaking change is absolutely required, create a new Lexicon with a new major version or a new name (e.g., `org.stormlightlabs.malfestio.noteV2`).
+1
-1
lexicons/app/malfestio/card.json
lexicons/org/stormlightlabs/malfestio/card.json
+1
-1
lexicons/app/malfestio/card.json
lexicons/org/stormlightlabs/malfestio/card.json
+1
-1
lexicons/app/malfestio/collection.json
lexicons/org/stormlightlabs/malfestio/collection.json
+1
-1
lexicons/app/malfestio/collection.json
lexicons/org/stormlightlabs/malfestio/collection.json
+1
-1
lexicons/app/malfestio/deck.json
lexicons/org/stormlightlabs/malfestio/deck.json
+1
-1
lexicons/app/malfestio/deck.json
lexicons/org/stormlightlabs/malfestio/deck.json
+1
-1
lexicons/app/malfestio/note.json
lexicons/org/stormlightlabs/malfestio/note.json
+1
-1
lexicons/app/malfestio/note.json
lexicons/org/stormlightlabs/malfestio/note.json
+1
-1
lexicons/app/malfestio/source/article.json
lexicons/org/stormlightlabs/malfestio/source/article.json
+1
-1
lexicons/app/malfestio/source/article.json
lexicons/org/stormlightlabs/malfestio/source/article.json
+1
-1
lexicons/app/malfestio/source/lecture.json
lexicons/org/stormlightlabs/malfestio/source/lecture.json
+1
-1
lexicons/app/malfestio/source/lecture.json
lexicons/org/stormlightlabs/malfestio/source/lecture.json
+1
-1
lexicons/app/malfestio/thread/comment.json
lexicons/org/stormlightlabs/malfestio/thread/comment.json
+1
-1
lexicons/app/malfestio/thread/comment.json
lexicons/org/stormlightlabs/malfestio/thread/comment.json
+5
migrations/012_2026_01_02_add_card_type.sql
+5
migrations/012_2026_01_02_add_card_type.sql
+22
-18
web/src/components/TutorialOverlay.tsx
+22
-18
web/src/components/TutorialOverlay.tsx
···
11
11
const tooltipWidth = 320;
12
12
13
13
switch (placement) {
14
-
case "top":
14
+
case "top": {
15
+
const tooltipHeight = 240;
15
16
return {
16
-
top: target.top - padding - 8,
17
+
top: target.top - padding - 16 - tooltipHeight,
17
18
left: target.left + target.width / 2 - tooltipWidth / 2,
18
-
transform: "translateY(-100%)",
19
19
};
20
+
}
20
21
case "bottom":
21
22
return { top: target.top + target.height + padding, left: target.left + target.width / 2 - tooltipWidth / 2 };
22
23
case "left":
···
94
95
exit={{ opacity: 0 }}
95
96
transition={{ duration: 0.2 }}
96
97
class="fixed inset-0 z-50 pointer-events-none">
97
-
<Show when={targetPos()}>
98
+
<Show when={targetPos()} keyed>
98
99
{(pos) => (
99
100
<>
100
101
<svg
···
104
105
<mask id="spotlight-mask">
105
106
<rect width="100%" height="100%" fill="white" />
106
107
<rect
107
-
x={pos().left - 8}
108
-
y={pos().top - 8}
109
-
width={pos().width + 16}
110
-
height={pos().height + 16}
108
+
x={pos.left - 8}
109
+
y={pos.top - 8}
110
+
width={pos.width + 16}
111
+
height={pos.height + 16}
111
112
rx="8"
112
113
fill="black" />
113
114
</mask>
···
120
121
onClick={() => tutorial.skipTutorial()} />
121
122
</svg>
122
123
123
-
<div
124
+
<Motion.div
124
125
class="absolute border-2 border-[#0F62FE] rounded-lg pointer-events-none"
125
126
style={{
126
-
top: `${pos().top - 8}px`,
127
-
left: `${pos().left - 8}px`,
128
-
width: `${pos().width + 16}px`,
129
-
height: `${pos().height + 16}px`,
127
+
top: `${pos.top - 8}px`,
128
+
left: `${pos.left - 8}px`,
129
+
width: `${pos.width + 16}px`,
130
+
height: `${pos.height + 16}px`,
130
131
"box-shadow": "0 0 0 4px rgba(15, 98, 254, 0.3)",
131
132
}} />
132
133
133
-
<div
134
+
<Motion.div
135
+
initial={{ opacity: 0, scale: 0.95 }}
136
+
animate={{ opacity: 1, scale: 1 }}
137
+
transition={{ duration: 0.2 }}
134
138
class="absolute w-80 bg-[#262626] border border-[#393939] rounded-lg shadow-xl p-4 pointer-events-auto"
135
139
style={{
136
-
top: `${getTooltipPosition(pos(), tutorial.currentStep()!.placement).top}px`,
137
-
left: `${getTooltipPosition(pos(), tutorial.currentStep()!.placement).left}px`,
138
-
transform: getTooltipPosition(pos(), tutorial.currentStep()!.placement).transform,
140
+
top: `${getTooltipPosition(pos, tutorial.currentStep()!.placement).top}px`,
141
+
left: `${getTooltipPosition(pos, tutorial.currentStep()!.placement).left}px`,
142
+
transform: getTooltipPosition(pos, tutorial.currentStep()!.placement).transform,
139
143
}}>
140
144
<div class="h-1 bg-[#393939] rounded-full mb-4 overflow-hidden">
141
145
<div
···
178
182
</Index>
179
183
</div>
180
184
<p class="text-xs text-[#525252] text-center mt-3">Use ← → arrow keys or Esc to skip</p>
181
-
</div>
185
+
</Motion.div>
182
186
</>
183
187
)}
184
188
</Show>
+2
-2
web/src/components/tests/DeckPreview.test.tsx
+2
-2
web/src/components/tests/DeckPreview.test.tsx
···
9
9
vi.mock(
10
10
"@solidjs/router",
11
11
() => ({
12
-
useSearchParams: () => [{ uri: "at://did:plc:test/app.malfestio.deck/123" }],
12
+
useSearchParams: () => [{ uri: "at://did:plc:test/org.stormlightlabs.malfestio.deck/123" }],
13
13
useNavigate: () => vi.fn(),
14
14
A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>,
15
15
}),
···
29
29
ok: true,
30
30
json: async () => ({
31
31
deck: {
32
-
id: "at://did:plc:test/app.malfestio.deck/123",
32
+
id: "at://did:plc:test/org.stormlightlabs.malfestio.deck/123",
33
33
owner_did: "did:plc:test",
34
34
title: "Remote Deck",
35
35
description: "A test deck",
+1
-1
web/src/components/tests/Library.test.tsx
+1
-1
web/src/components/tests/Library.test.tsx