1use crate::api::error::AppError;
3use crate::db::pool::get_conn;
4use crate::models::note::*;
5use crate::models::public_note::PublicNote;
6
7#[derive(Clone)]
8pub enum Access {
9 Public,
10 Owner(i32),
11 Knowledge {
12 owner_id: i32,
13 notebook_ids: Option<Vec<i32>>,
14 },
15}
16
17impl Access {
18 fn revision(&self) -> &'static str {
19 match self {
20 Self::Public => "published_version",
21 Self::Owner(_) => "version",
22 Self::Knowledge { .. } => "knowledge_version",
23 }
24 }
25 fn owner(&self) -> Option<i32> {
26 match self {
27 Self::Public => None,
28 Self::Owner(id) => Some(*id),
29 Self::Knowledge { owner_id, .. } => Some(*owner_id),
30 }
31 }
32 fn books(&self) -> Option<Vec<i32>> {
33 match self {
34 Self::Knowledge { notebook_ids, .. } => notebook_ids.clone(),
35 _ => None,
36 }
37 }
38}
39
40fn map_note(row: &tokio_postgres::Row, access: &Access) -> Note {
41 let admin = matches!(access, Access::Owner(_));
42 let toc_html: String = row.get("toc_html");
43 let sections = crate::utils::toc::toc_sections(&toc_html, 0).total;
44 Note {
45 id: row.get("id"),
46 slug: row.get("slug"),
47 version: row.get("revision"),
48 kind: if row.get::<_, String>("kind") == "topic" {
49 NoteKind::Topic
50 } else {
51 NoteKind::Moment
52 },
53 title: row.get("title"),
54 content_md: row.get("content_md"),
55 content_html: row.get("content_html"),
56 toc_html,
57 summary: row.get("summary"),
58 tags: row.get("tags"),
59 created_at: row.get("created_at"),
60 updated_at: row.get("revision_at"),
61 published_at: row.get("published_at"),
62 published_version: if admin {
63 row.get("published_version")
64 } else {
65 None
66 },
67 knowledge_version: if admin {
68 row.get("knowledge_version")
69 } else {
70 None
71 },
72 deleted_at: if admin { row.get("deleted_at") } else { None },
73 notebook_ids: row.get("notebook_ids"),
74 sections: u32::try_from(sections).unwrap_or(u32::MAX),
75 }
76}
77
78const COLUMNS: &str = "n.id, n.slug, n.created_at, n.published_at, n.published_version,
79 n.knowledge_version, n.deleted_at, r.version AS revision, r.kind, r.title,
80 r.content_md, r.content_html, r.toc_html, r.summary, r.tags, r.created_at AS revision_at";
81
82const BOOK_IDS: &str =
84 "ARRAY(SELECT b.id FROM notebooks b JOIN notebook_notes bn ON bn.notebook_id=b.id
85 WHERE bn.note_id=n.id AND ($1::int IS NOT NULL OR (b.is_public AND b.archived_at IS NULL))
86 AND ($2::int[] IS NULL OR b.id=ANY($2)) ORDER BY b.id) AS notebook_ids";
87
88pub async fn list(access: Access, filter: NoteFilter) -> Result<NotePage, AppError> {
89 if filter.query.chars().count() > 200 {
90 return Err(AppError::BadRequest("搜索内容不能超过 200 字".into()));
91 }
92 let client = get_conn().await.map_err(AppError::db_conn)?;
93 let owner = access.owner();
94 let books = access.books();
95 let trash = matches!(access, Access::Owner(_)) && filter.trash;
96 let (published, knowledge) = if matches!(access, Access::Owner(_)) {
97 (filter.published, filter.knowledge)
98 } else {
99 (None, None)
100 };
101 let query = crate::utils::server::escape_like_pattern(filter.query.trim());
102 let kind = filter.kind.map(|k| k.as_str());
103 let predicate = format!("FROM notes n JOIN note_revisions r ON r.note_id=n.id AND r.version=n.{}
104 WHERE ($1::int IS NULL OR n.owner_id=$1)
105 AND ($2::int[] IS NULL OR EXISTS (SELECT 1 FROM notebook_notes bn WHERE bn.note_id=n.id AND bn.notebook_id=ANY($2)))
106 AND (n.deleted_at IS NOT NULL)=$3
107 AND ($4='' OR r.search_text ILIKE '%' || $4 || '%' ESCAPE '\\' OR EXISTS (SELECT 1 FROM unnest(r.tags) tag WHERE tag ILIKE '%' || $4 || '%' ESCAPE '\\'))
108 AND ($5::text IS NULL OR r.kind=$5)
109 AND ($6::int IS NULL OR EXISTS (SELECT 1 FROM notebook_notes bn JOIN notebooks b ON b.id=bn.notebook_id WHERE bn.note_id=n.id AND b.id=$6 AND ($1::int IS NOT NULL OR (b.is_public AND b.archived_at IS NULL))))
110 AND ($7='' OR $7=ANY(r.tags))
111 AND ($8::bool IS NULL OR (n.published_version IS NOT NULL)=$8)
112 AND ($9::bool IS NULL OR (n.knowledge_version IS NOT NULL)=$9)", access.revision());
113 let params: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[
114 &owner,
115 &books,
116 &trash,
117 &query,
118 &kind,
119 &filter.notebook_id,
120 &filter.tag,
121 &published,
122 &knowledge,
123 ];
124 let total = client
125 .query_one(&format!("SELECT COUNT(*) {predicate}"), params)
126 .await
127 .map_err(AppError::query)?
128 .get(0);
129 let page = filter.page.clamp(1, 100_000);
130 let columns = COLUMNS.replace("r.content_md, r.content_html, r.toc_html", "''::text AS content_md, CASE WHEN r.kind='moment' AND length(r.content_md)<=2000 THEN r.content_html ELSE '' END AS content_html, CASE WHEN r.kind='topic' THEN r.toc_html ELSE '' END AS toc_html");
133 let sql = format!("SELECT {columns}, {BOOK_IDS} {predicate}
134 ORDER BY CASE WHEN $6::int IS NOT NULL THEN (SELECT position FROM notebook_notes WHERE notebook_id=$6 AND note_id=n.id) END,
135 CASE WHEN $4<>'' THEN word_similarity($4, r.search_text) END DESC,
136 r.created_at DESC, n.id DESC LIMIT 20 OFFSET {}", (page-1)*20);
137 let notes = client
138 .query(&sql, params)
139 .await
140 .map_err(AppError::query)?
141 .iter()
142 .map(|r| Note {
143 toc_html: String::new(),
144 ..map_note(r, &access)
145 })
146 .collect();
147 Ok(NotePage { notes, total })
148}
149
150pub async fn stats(tag_limit: i64) -> Result<NoteStats, AppError> {
152 let client = get_conn().await.map_err(AppError::db_conn)?;
153 let counts = client
154 .query_one(
155 "SELECT COUNT(*) FILTER (WHERE r.kind='moment'), COUNT(*) FILTER (WHERE r.kind='topic')
156 FROM notes n JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version
157 WHERE n.deleted_at IS NULL",
158 &[],
159 )
160 .await
161 .map_err(AppError::query)?;
162 let tags = client
163 .query(
164 "SELECT tag, COUNT(*) FROM notes n
165 JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version,
166 unnest(r.tags) tag
167 WHERE n.deleted_at IS NULL GROUP BY tag ORDER BY COUNT(*) DESC, tag LIMIT $1",
168 &[&tag_limit],
169 )
170 .await
171 .map_err(AppError::query)?
172 .iter()
173 .map(|r| (r.get(0), r.get(1)))
174 .collect();
175 Ok(NoteStats {
176 moments: counts.get(0),
177 topics: counts.get(1),
178 tags,
179 })
180}
181
182pub async fn context(slug: &str, requested: Option<i32>) -> Result<Option<NoteContext>, AppError> {
185 let client = get_conn().await.map_err(AppError::db_conn)?;
186 let candidates: Vec<i32> = client
187 .query(
188 "SELECT b.id FROM notebooks b
189 JOIN notebook_notes bn ON bn.notebook_id=b.id JOIN notes n ON n.id=bn.note_id
190 WHERE n.slug=$1 AND n.deleted_at IS NULL AND n.published_version IS NOT NULL
191 AND b.is_public AND b.archived_at IS NULL
192 ORDER BY b.id",
193 &[&slug],
194 )
195 .await
196 .map_err(AppError::query)?
197 .iter()
198 .map(|r| r.get(0))
199 .collect();
200 let Some(book) = choose_notebook(&candidates, requested) else {
201 return Ok(None);
202 };
203 let siblings = client
204 .query(
205 "SELECT n.slug, r.kind, r.title, r.summary FROM notebook_notes bn
206 JOIN notes n ON n.id=bn.note_id
207 JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version
208 WHERE bn.notebook_id=$1 AND n.deleted_at IS NULL
209 ORDER BY bn.position, n.id",
210 &[&book],
211 )
212 .await
213 .map_err(AppError::query)?
214 .iter()
215 .map(|r| {
216 let kind = if r.get::<_, &str>(1) == "topic" {
217 NoteKind::Topic
218 } else {
219 NoteKind::Moment
220 };
221 NoteLink {
222 slug: r.get(0),
223 title: display_title(kind, r.get(2), r.get(3)),
224 }
225 })
226 .collect();
227 let Some(notebook) = notebooks(Access::Public)
228 .await?
229 .into_iter()
230 .find(|b| b.id == book)
231 else {
232 return Ok(None);
233 };
234 Ok(NoteContext::locate(notebook, siblings, slug))
235}
236
237pub async fn get(
238 access: Access,
239 id: Option<i32>,
240 slug: Option<String>,
241 version: Option<i32>,
242) -> Result<Note, AppError> {
243 let client = get_conn().await.map_err(AppError::db_conn)?;
244 let owner = access.owner();
245 let books = access.books();
246 let requested = if matches!(access, Access::Owner(_)) {
247 version
248 } else {
249 None
250 };
251 let sql = format!("SELECT {COLUMNS}, {BOOK_IDS} FROM notes n
252 JOIN note_revisions r ON r.note_id=n.id AND r.version=COALESCE($5, n.{})
253 WHERE ($1::int IS NULL OR n.owner_id=$1)
254 AND ($2::int[] IS NULL OR EXISTS (SELECT 1 FROM notebook_notes bn WHERE bn.note_id=n.id AND bn.notebook_id=ANY($2)))
255 AND (($3::int IS NOT NULL AND n.id=$3) OR ($4::text IS NOT NULL AND n.slug=$4))
256 AND (n.deleted_at IS NULL OR $6)", access.revision());
257 let row = client
258 .query_opt(
259 &sql,
260 &[
261 &owner,
262 &books,
263 &id,
264 &slug,
265 &requested,
266 &matches!(access, Access::Owner(_)),
267 ],
268 )
269 .await
270 .map_err(AppError::query)?
271 .ok_or(AppError::NotFound("笔记不存在或无权访问"))?;
272 Ok(map_note(&row, &access))
273}
274
275pub async fn get_public(slug: &str) -> Result<PublicNote, AppError> {
283 let client = get_conn().await.map_err(AppError::db_conn)?;
284 let row = client
285 .query_opt(
286 "SELECT n.id, n.slug, r.version AS revision, r.kind, r.title, r.summary,
287 r.tags, r.content_html, r.toc_html,
288 r.created_at AS revision_at, n.created_at
289 FROM notes n
290 JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version
291 WHERE n.slug=$1 AND n.deleted_at IS NULL",
292 &[&slug],
293 )
294 .await
295 .map_err(AppError::query)?
296 .ok_or(AppError::NotFound("笔记不存在或未公开"))?;
297 let toc_html: String = row.get("toc_html");
298 let sections = crate::utils::toc::toc_sections(&toc_html, 0).total;
299 Ok(PublicNote {
300 id: row.get("id"),
301 slug: row.get("slug"),
302 version: row.get("revision"),
303 kind: if row.get::<_, String>("kind") == "topic" {
304 NoteKind::Topic
305 } else {
306 NoteKind::Moment
307 },
308 title: row.get("title"),
309 summary: row.get("summary"),
310 content_html: row.get("content_html"),
311 toc_html,
312 tags: row.get("tags"),
313 created_at: row.get("created_at"),
314 updated_at: row.get("revision_at"),
315 sections: u32::try_from(sections).unwrap_or(u32::MAX),
316 })
317}
318
319fn validate(draft: &mut NoteDraft) -> Result<(), AppError> {
320 draft.title = draft.title.trim().to_string();
321 if draft.title.chars().count() > 200 || draft.content_md.len() > 200_000 {
322 return Err(AppError::BadRequest(
323 "标题最多 200 字,正文最多 200 KB".into(),
324 ));
325 }
326 draft.tags = crate::api::posts::helpers::clean_tags(&draft.tags);
327 if draft.tags.len() > 16 || draft.tags.iter().any(|t| t.chars().count() > 40) {
328 return Err(AppError::BadRequest(
329 "最多 16 个标签,每个标签最多 40 字".into(),
330 ));
331 }
332 draft.notebook_ids.sort_unstable();
333 draft.notebook_ids.dedup();
334 if draft.notebook_ids.len() > 30 {
335 return Err(AppError::BadRequest("最多收录到 30 个笔记本".into()));
336 }
337 if draft.id.is_some() && draft.expected_version.is_none() {
338 return Err(AppError::BadRequest("更新笔记必须提供当前版本".into()));
339 }
340 Ok(())
341}
342
343pub async fn save(
344 owner_id: i32,
345 mut draft: NoteDraft,
346 allowed_books: Option<&[i32]>,
347) -> Result<Note, AppError> {
348 validate(&mut draft)?;
349 if let Some(allowed) = allowed_books {
350 if draft.notebook_ids.is_empty() || draft.notebook_ids.iter().any(|b| !allowed.contains(b))
351 {
352 return Err(AppError::Forbidden("笔记必须位于令牌授权的笔记本中"));
353 }
354 }
355 let fields =
356 crate::api::posts::helpers::render_post_fields(&draft.content_md, "draft", None).await?;
357 let mut client = get_conn().await.map_err(AppError::db_conn)?;
358 let tx = client.transaction().await.map_err(AppError::tx)?;
359 let count: i64 = tx
360 .query_one(
361 "SELECT COUNT(*) FROM notebooks WHERE owner_id=$1 AND id=ANY($2)",
362 &[&owner_id, &draft.notebook_ids],
363 )
364 .await
365 .map_err(AppError::query)?
366 .get(0);
367 if count != draft.notebook_ids.len() as i64 {
368 return Err(AppError::Forbidden("笔记本不存在或无权访问"));
369 }
370 let (id, version) = if let Some(id) = draft.id {
371 let row = tx.query_opt("SELECT version FROM notes WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL FOR UPDATE", &[&id, &owner_id]).await.map_err(AppError::query)?.ok_or(AppError::NotFound("笔记不存在或已移入回收站"))?;
372 let current: i32 = row.get(0);
373 if Some(current) != draft.expected_version {
374 return Err(AppError::BadRequest(
375 "笔记已在别处修改,请重新加载后再保存;当前编辑内容仍保留在页面".into(),
376 ));
377 }
378 if let Some(allowed) = allowed_books {
379 let found: bool = tx.query_one("SELECT EXISTS(SELECT 1 FROM notebook_notes WHERE note_id=$1 AND notebook_id=ANY($2))", &[&id, &allowed]).await.map_err(AppError::query)?.get(0);
380 if !found {
381 return Err(AppError::Forbidden("笔记不在令牌授权范围内"));
382 }
383 }
384 (id, current + 1)
385 } else {
386 let slug = uuid::Uuid::new_v4().simple().to_string();
387 let id = tx
388 .query_one(
389 "INSERT INTO notes(owner_id, slug) VALUES ($1,$2) RETURNING id",
390 &[&owner_id, &slug],
391 )
392 .await
393 .map_err(AppError::tx)?
394 .get(0);
395 (id, 1)
396 };
397 tx.execute("INSERT INTO note_revisions(note_id, version, kind, title, content_md, content_html, toc_html, summary, tags) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)",
398 &[&id, &version, &draft.kind.as_str(), &draft.title, &draft.content_md, &fields.content_html, &fields.toc_html.unwrap_or_default(), &fields.auto_summary, &draft.tags]).await.map_err(AppError::tx)?;
399 tx.execute(
400 "UPDATE notes SET version=$2, updated_at=NOW() WHERE id=$1",
401 &[&id, &version],
402 )
403 .await
404 .map_err(AppError::tx)?;
405 let allowed = allowed_books.map(|b| b.to_vec());
406 tx.execute("DELETE FROM notebook_notes WHERE note_id=$1 AND NOT(notebook_id=ANY($2)) AND ($3::int[] IS NULL OR notebook_id=ANY($3))", &[&id, &draft.notebook_ids, &allowed]).await.map_err(AppError::tx)?;
407 for book in &draft.notebook_ids {
408 tx.execute("INSERT INTO notebook_notes(notebook_id, note_id, position) SELECT $1,$2,COALESCE(MAX(position),-1)+1 FROM notebook_notes WHERE notebook_id=$1 ON CONFLICT DO NOTHING", &[book, &id]).await.map_err(AppError::tx)?;
409 }
410 super::attachments::sync_refs(&tx, owner_id, id, version, &fields.content_html).await?;
411 tx.commit().await.map_err(AppError::tx)?;
412 invalidate();
414 get(Access::Owner(owner_id), Some(id), None, None).await
415}
416
417pub async fn act(
418 owner_id: i32,
419 id: i32,
420 expected: i32,
421 action: NoteAction,
422 allowed_books: Option<&[i32]>,
423) -> Result<Note, AppError> {
424 let mut client = get_conn().await.map_err(AppError::db_conn)?;
425 let tx = client.transaction().await.map_err(AppError::tx)?;
426 act_in_transaction(&tx, owner_id, id, expected, action, allowed_books).await?;
427 tx.commit().await.map_err(AppError::tx)?;
428 invalidate();
429 get(Access::Owner(owner_id), Some(id), None, None).await
430}
431
432pub async fn batch_act(
433 owner_id: i32,
434 mut targets: Vec<NoteBatchTarget>,
435 action: NoteAction,
436) -> Result<(), AppError> {
437 if !matches!(
438 action,
439 NoteAction::Publish
440 | NoteAction::Unpublish
441 | NoteAction::IncludeKnowledge
442 | NoteAction::ExcludeKnowledge
443 ) {
444 return Err(AppError::BadRequest("不支持此批量操作".into()));
445 }
446 if targets.is_empty() || targets.len() > 100 {
447 return Err(AppError::BadRequest("每次请选择 1 至 100 篇笔记".into()));
448 }
449 targets.sort_unstable_by_key(|target| target.id);
450 if targets.windows(2).any(|pair| pair[0].id == pair[1].id) {
451 return Err(AppError::BadRequest("不能重复选择同一篇笔记".into()));
452 }
453 let mut client = get_conn().await.map_err(AppError::db_conn)?;
454 let tx = client.transaction().await.map_err(AppError::tx)?;
455 for target in targets {
457 act_in_transaction(
458 &tx,
459 owner_id,
460 target.id,
461 target.expected_version,
462 action,
463 None,
464 )
465 .await?;
466 }
467 tx.commit().await.map_err(AppError::tx)?;
468 invalidate();
469 Ok(())
470}
471
472async fn act_in_transaction(
473 tx: &deadpool_postgres::Transaction<'_>,
474 owner_id: i32,
475 id: i32,
476 expected: i32,
477 action: NoteAction,
478 allowed_books: Option<&[i32]>,
479) -> Result<(), AppError> {
480 let row = tx.query_opt("SELECT n.version, n.deleted_at, r.kind, r.title, r.content_md FROM notes n JOIN note_revisions r ON r.note_id=n.id AND r.version=n.version WHERE n.id=$1 AND n.owner_id=$2 FOR UPDATE OF n", &[&id, &owner_id]).await.map_err(AppError::query)?.ok_or(AppError::NotFound("笔记不存在"))?;
481 if let Some(allowed) = allowed_books {
483 let found: bool = tx
484 .query_one(
485 "SELECT EXISTS(SELECT 1 FROM notebook_notes WHERE note_id=$1 AND notebook_id=ANY($2))",
486 &[&id, &allowed],
487 )
488 .await
489 .map_err(AppError::query)?
490 .get(0);
491 if !found {
492 return Err(AppError::Forbidden("笔记不在令牌授权范围内"));
493 }
494 }
495 if row.get::<_, i32>("version") != expected {
496 return Err(AppError::BadRequest("笔记版本已变化,请重新加载".into()));
497 }
498 if row
499 .get::<_, Option<chrono::DateTime<chrono::Utc>>>("deleted_at")
500 .is_some()
501 && action != NoteAction::Restore
502 {
503 return Err(AppError::BadRequest("请先从回收站恢复笔记".into()));
504 }
505 if matches!(action, NoteAction::Publish | NoteAction::IncludeKnowledge)
506 && (row.get::<_, String>("content_md").trim().is_empty()
507 || (row.get::<_, String>("kind") == "topic"
508 && row.get::<_, String>("title").trim().is_empty()))
509 {
510 return Err(AppError::BadRequest(
511 "请先填写正文;主题笔记还需要标题".into(),
512 ));
513 }
514 let update = match action {
515 NoteAction::Publish => {
516 "published_version=version, published_at=COALESCE(published_at,NOW())"
517 }
518 NoteAction::Unpublish => "published_version=NULL, published_at=NULL",
519 NoteAction::IncludeKnowledge => "knowledge_version=version",
520 NoteAction::ExcludeKnowledge => "knowledge_version=NULL",
521 NoteAction::Trash => {
522 "deleted_at=NOW(), published_version=NULL, published_at=NULL, knowledge_version=NULL"
523 }
524 NoteAction::Restore => "deleted_at=NULL",
525 };
526 tx.execute(
527 &format!("UPDATE notes SET {update}, updated_at=NOW() WHERE id=$1"),
528 &[&id],
529 )
530 .await
531 .map_err(AppError::tx)?;
532 Ok(())
533}
534
535pub async fn history(owner_id: i32, id: i32) -> Result<Vec<NoteRevision>, AppError> {
536 let client = get_conn().await.map_err(AppError::db_conn)?;
537 let rows = client.query("SELECT r.version,r.title,r.created_at FROM note_revisions r JOIN notes n ON n.id=r.note_id WHERE n.id=$1 AND n.owner_id=$2 ORDER BY r.version DESC LIMIT 100", &[&id, &owner_id]).await.map_err(AppError::query)?;
538 Ok(rows
539 .iter()
540 .map(|r| NoteRevision {
541 version: r.get(0),
542 title: r.get(1),
543 created_at: r.get(2),
544 })
545 .collect())
546}
547
548pub async fn notebooks(access: Access) -> Result<Vec<Notebook>, AppError> {
549 let client = get_conn().await.map_err(AppError::db_conn)?;
550 let owner = access.owner();
551 let books = access.books();
552 let include_archived = matches!(access, Access::Owner(_));
553 let sql = format!("SELECT b.*, (SELECT COUNT(*) FROM notebook_notes bn JOIN notes n ON n.id=bn.note_id WHERE bn.notebook_id=b.id AND n.deleted_at IS NULL AND n.{} IS NOT NULL) AS note_count
554 FROM notebooks b WHERE ($1::int IS NULL AND b.is_public OR b.owner_id=$1)
555 AND ($2::int[] IS NULL OR b.id=ANY($2)) AND ($3 OR b.archived_at IS NULL)
556 ORDER BY b.updated_at DESC,b.id DESC", access.revision());
557 let rows = client
558 .query(&sql, &[&owner, &books, &include_archived])
559 .await
560 .map_err(AppError::query)?;
561 Ok(rows
562 .iter()
563 .map(|r| Notebook {
564 id: r.get("id"),
565 title: r.get("title"),
566 description: r.get("description"),
567 is_public: r.get("is_public"),
568 archived_at: r.get("archived_at"),
569 note_count: r.get("note_count"),
570 updated_at: r.get("updated_at"),
571 })
572 .collect())
573}
574
575pub async fn save_notebook(owner_id: i32, input: NotebookInput) -> Result<(), AppError> {
576 let title = input.title.trim();
577 if title.is_empty() || title.chars().count() > 100 || input.description.chars().count() > 1000 {
578 return Err(AppError::BadRequest(
579 "笔记本标题为 1–100 字,介绍最多 1000 字".into(),
580 ));
581 }
582 let client = get_conn().await.map_err(AppError::db_conn)?;
583 if let Some(id) = input.id {
584 let changed = client.execute("UPDATE notebooks SET title=$3,description=$4,is_public=$5,updated_at=NOW() WHERE id=$1 AND owner_id=$2 AND (archived_at IS NULL OR NOT $5)", &[&id,&owner_id,&title,&input.description,&input.is_public]).await.map_err(AppError::query)?;
585 if changed == 0 {
586 return Err(AppError::BadRequest(
587 "笔记本不存在,或已归档;请先恢复再公开".into(),
588 ));
589 }
590 } else {
591 client
592 .execute(
593 "INSERT INTO notebooks(owner_id,title,description,is_public) VALUES ($1,$2,$3,$4)",
594 &[&owner_id, &title, &input.description, &input.is_public],
595 )
596 .await
597 .map_err(AppError::query)?;
598 }
599 invalidate();
600 Ok(())
601}
602
603pub async fn archive_notebook(owner_id: i32, id: i32, archived: bool) -> Result<(), AppError> {
604 let client = get_conn().await.map_err(AppError::db_conn)?;
605 let changed = client.execute(
606 "UPDATE notebooks SET archived_at=CASE WHEN $3 THEN COALESCE(archived_at,NOW()) ELSE NULL END,
607 is_public=CASE WHEN $3 THEN FALSE ELSE is_public END, updated_at=NOW() WHERE id=$1 AND owner_id=$2",
608 &[&id,&owner_id,&archived],
609 ).await.map_err(AppError::query)?;
610 if changed == 0 {
611 return Err(AppError::NotFound("笔记本不存在"));
612 }
613 invalidate();
614 Ok(())
615}
616
617pub async fn reorder(owner_id: i32, book: i32, ids: Vec<i32>) -> Result<(), AppError> {
618 let mut client = get_conn().await.map_err(AppError::db_conn)?;
619 let tx = client.transaction().await.map_err(AppError::tx)?;
620 tx.query_opt(
621 "SELECT id FROM notebooks WHERE id=$1 AND owner_id=$2 FOR UPDATE",
622 &[&book, &owner_id],
623 )
624 .await
625 .map_err(AppError::query)?
626 .ok_or(AppError::NotFound("笔记本不存在"))?;
627 let existing: Vec<i32> = tx
628 .query(
629 "SELECT note_id FROM notebook_notes WHERE notebook_id=$1 ORDER BY position,note_id",
630 &[&book],
631 )
632 .await
633 .map_err(AppError::query)?
634 .iter()
635 .map(|r| r.get(0))
636 .collect();
637 let mut a = existing;
638 a.sort_unstable();
639 let mut b = ids.clone();
640 b.sort_unstable();
641 if a != b {
642 return Err(AppError::BadRequest(
643 "笔记本内容已变化,请刷新后排序".into(),
644 ));
645 }
646 for (position, id) in ids.iter().enumerate() {
647 tx.execute(
648 "UPDATE notebook_notes SET position=$3 WHERE notebook_id=$1 AND note_id=$2",
649 &[&book, id, &(position as i32)],
650 )
651 .await
652 .map_err(AppError::tx)?;
653 }
654 tx.commit().await.map_err(AppError::tx)?;
655 invalidate();
656 Ok(())
657}
658
659fn invalidate() {
660 crate::ssr_cache::bump_global_generation();
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
668 #[ignore = "requires disposable DATABASE_URL database ygg_notes_test"]
669 fn notes_database_batch_actions_are_atomic_and_revision_specific() {
670 crate::db::TEST_DATABASE_RUNTIME.block_on(async {
671 let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
672 let mut client = get_conn().await.unwrap();
673 let database: String = client
674 .query_one("SELECT current_database()", &[])
675 .await
676 .unwrap()
677 .get(0);
678 assert_eq!(
679 database, "ygg_notes_test",
680 "requires isolated test database"
681 );
682 crate::db::migrate::run_on_conn(&mut client).await.unwrap();
683 client
684 .batch_execute(
685 "TRUNCATE users CASCADE;
686 INSERT INTO users(id,username,email,password_hash,role) VALUES
687 (1,'batch-owner','batch@test.invalid','unused','admin'),
688 (2,'batch-other','batch-other@test.invalid','unused','blocked');",
689 )
690 .await
691 .unwrap();
692 drop(client);
693 let mut notes = Vec::new();
694 for title in ["批量甲", "批量乙"] {
695 notes.push(
696 save(
697 1,
698 NoteDraft {
699 title: title.into(),
700 kind: NoteKind::Topic,
701 content_md: "初始工作稿".into(),
702 ..Default::default()
703 },
704 None,
705 )
706 .await
707 .unwrap(),
708 );
709 }
710 let targets: Vec<_> = notes
712 .iter()
713 .rev()
714 .map(|note| NoteBatchTarget {
715 id: note.id,
716 expected_version: note.version,
717 })
718 .collect();
719 batch_act(1, targets.clone(), NoteAction::IncludeKnowledge)
720 .await
721 .unwrap();
722 for note in ¬es {
723 let current = get(Access::Owner(1), Some(note.id), None, None)
724 .await
725 .unwrap();
726 assert_eq!(current.published_version, None);
727 assert_eq!(current.knowledge_version, Some(1));
728 }
729 for note in &mut notes {
731 let mut draft = note.draft();
732 draft.content_md = format!("{} 的最新工作稿", note.title);
733 *note = save(1, draft, None).await.unwrap();
734 }
735 let targets: Vec<_> = notes
736 .iter()
737 .rev()
738 .map(|note| NoteBatchTarget {
739 id: note.id,
740 expected_version: note.version,
741 })
742 .collect();
743 batch_act(1, targets.clone(), NoteAction::Publish)
744 .await
745 .unwrap();
746 for note in ¬es {
747 let current = get(Access::Owner(1), Some(note.id), None, None)
748 .await
749 .unwrap();
750 assert_eq!(current.published_version, Some(2));
751 assert_eq!(current.knowledge_version, Some(1));
752 let public = get(Access::Public, Some(note.id), None, None)
753 .await
754 .unwrap();
755 assert_eq!(public.version, 2);
756 assert_eq!(public.content_md, note.content_md);
757 }
758 batch_act(1, targets.clone(), NoteAction::ExcludeKnowledge)
759 .await
760 .unwrap();
761 for note in ¬es {
762 let current = get(Access::Owner(1), Some(note.id), None, None)
763 .await
764 .unwrap();
765 assert_eq!(current.published_version, Some(2));
766 assert_eq!(current.knowledge_version, None);
767 }
768 batch_act(1, targets.clone(), NoteAction::IncludeKnowledge)
769 .await
770 .unwrap();
771 batch_act(1, targets.clone(), NoteAction::Unpublish)
772 .await
773 .unwrap();
774 for note in ¬es {
775 let current = get(Access::Owner(1), Some(note.id), None, None)
776 .await
777 .unwrap();
778 assert_eq!(current.published_version, None);
779 assert_eq!(current.published_at, None);
780 assert_eq!(current.knowledge_version, Some(2));
781 }
782
783 let foreign = save(
785 2,
786 NoteDraft {
787 content_md: "他人的笔记".into(),
788 ..Default::default()
789 },
790 None,
791 )
792 .await
793 .unwrap();
794 let trashed = save(
795 1,
796 NoteDraft {
797 content_md: "回收站笔记".into(),
798 ..Default::default()
799 },
800 None,
801 )
802 .await
803 .unwrap();
804 act(1, trashed.id, 1, NoteAction::Trash, None)
805 .await
806 .unwrap();
807 let empty = save(1, NoteDraft::default(), None).await.unwrap();
808 let untitled = save(
809 1,
810 NoteDraft {
811 kind: NoteKind::Topic,
812 content_md: "没有标题的主题".into(),
813 ..Default::default()
814 },
815 None,
816 )
817 .await
818 .unwrap();
819 let invalid = [
820 NoteBatchTarget {
821 id: notes[1].id,
822 expected_version: 1,
823 },
824 NoteBatchTarget {
825 id: foreign.id,
826 expected_version: 1,
827 },
828 NoteBatchTarget {
829 id: trashed.id,
830 expected_version: 1,
831 },
832 NoteBatchTarget {
833 id: empty.id,
834 expected_version: 1,
835 },
836 NoteBatchTarget {
837 id: untitled.id,
838 expected_version: 1,
839 },
840 NoteBatchTarget {
841 id: i32::MAX,
842 expected_version: 1,
843 },
844 ];
845 for action in [NoteAction::Publish, NoteAction::IncludeKnowledge] {
846 for target in invalid {
847 let before = get(Access::Owner(1), Some(notes[0].id), None, None)
848 .await
849 .unwrap();
850 assert!(
851 batch_act(1, vec![targets[1], target], action)
852 .await
853 .is_err(),
854 "invalid target must abort the entire batch: {target:?}, {action:?}"
855 );
856 assert_eq!(
857 get(Access::Owner(1), Some(notes[0].id), None, None)
858 .await
859 .unwrap(),
860 before,
861 "no partial state or timestamp writes"
862 );
863 }
864 }
865 for action in [NoteAction::Unpublish, NoteAction::ExcludeKnowledge] {
867 for target in &invalid[..3] {
868 let before = get(Access::Owner(1), Some(notes[0].id), None, None)
869 .await
870 .unwrap();
871 assert!(batch_act(1, vec![targets[1], *target], action)
872 .await
873 .is_err());
874 assert_eq!(
875 get(Access::Owner(1), Some(notes[0].id), None, None)
876 .await
877 .unwrap(),
878 before
879 );
880 }
881 }
882 for action in [NoteAction::Trash, NoteAction::Restore] {
883 assert!(batch_act(1, targets.clone(), action).await.is_err());
884 }
885 assert!(batch_act(1, Vec::new(), NoteAction::Publish).await.is_err());
886 assert!(batch_act(1, vec![targets[0]; 101], NoteAction::Publish)
887 .await
888 .is_err());
889 assert!(batch_act(1, vec![targets[0]; 2], NoteAction::Publish)
890 .await
891 .is_err());
892 for note in ¬es {
893 assert!(get(Access::Owner(1), Some(note.id), None, None)
894 .await
895 .unwrap()
896 .deleted_at
897 .is_none());
898 }
899 });
900 }
901
902 #[test]
903 #[ignore = "requires disposable DATABASE_URL database ygg_notes_test"]
904 fn notes_database_workflow() {
905 crate::db::TEST_DATABASE_RUNTIME.block_on(async {
906 let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
907 let mut client = get_conn().await.unwrap();
908 let database: String = client
909 .query_one("SELECT current_database()", &[])
910 .await
911 .unwrap()
912 .get(0);
913 assert_eq!(
914 database, "ygg_notes_test",
915 "requires isolated test database"
916 );
917 crate::db::migrate::run_on_conn(&mut client).await.unwrap();
918 client
919 .batch_execute(
920 "TRUNCATE users CASCADE;
921 INSERT INTO users(id,username,email,password_hash,role) VALUES
922 (1,'note-owner','note@test.invalid','unused','admin'),
923 (2,'note-other','other@test.invalid','unused','blocked');",
924 )
925 .await
926 .unwrap();
927 save_notebook(
928 1,
929 NotebookInput {
930 title: "公开主题".into(),
931 is_public: true,
932 ..Default::default()
933 },
934 )
935 .await
936 .unwrap();
937 save_notebook(
938 1,
939 NotebookInput {
940 title: "私密主题".into(),
941 ..Default::default()
942 },
943 )
944 .await
945 .unwrap();
946 let books = notebooks(Access::Owner(1)).await.unwrap();
947 let public = books.iter().find(|b| b.is_public).unwrap().id;
948 let private = books.iter().find(|b| !b.is_public).unwrap().id;
949 let note = save(
950 1,
951 NoteDraft {
952 title: "所有权".into(),
953 kind: NoteKind::Topic,
954 content_md: "第一版 Rust 100%_".into(),
955 notebook_ids: vec![public, private],
956 ..Default::default()
957 },
958 None,
959 )
960 .await
961 .unwrap();
962 assert!(get(Access::Public, Some(note.id), None, None)
963 .await
964 .is_err());
965 assert!(get(Access::Owner(2), Some(note.id), None, None)
966 .await
967 .is_err());
968 assert_eq!(
969 list(Access::Public, NoteFilter::default())
970 .await
971 .unwrap()
972 .total,
973 0
974 );
975 assert_eq!(notebooks(Access::Public).await.unwrap()[0].note_count, 0);
976 act(1, note.id, 1, NoteAction::Publish, None).await.unwrap();
977 act(1, note.id, 1, NoteAction::IncludeKnowledge, None)
978 .await
979 .unwrap();
980 let mut draft = note.draft();
981 draft.content_md = "第二版,尚未发布".into();
982 let updated = save(1, draft.clone(), None).await.unwrap();
983 assert_eq!(updated.version, 2);
984 assert!(
985 save(1, draft, None).await.is_err(),
986 "stale writes must fail"
987 );
988 let published = get(Access::Public, Some(note.id), None, Some(2))
989 .await
990 .unwrap();
991 assert_eq!(
992 published.version, 1,
993 "public cannot select a draft revision"
994 );
995 assert_eq!(published.notebook_ids, vec![public]);
996 assert!(published.knowledge_version.is_none());
997 let public_note = get_public(note.slug.as_str()).await.unwrap();
1000 assert_eq!(public_note.version, 1, "公开详情固定为已发布版本");
1001 assert_eq!(public_note.content_html, published.content_html);
1002 assert_eq!(public_note.updated_at, published.updated_at);
1003 let public_payload = serde_json::to_value(&public_note).unwrap();
1004 assert!(public_payload.get("content_md").is_none());
1005 assert!(public_payload.get("knowledge_version").is_none());
1006 assert!(public_payload.get("notebook_ids").is_none());
1007 assert!(get_public("绝不存在的别名").await.is_err());
1008 let knowledge = Access::Knowledge {
1009 owner_id: 1,
1010 notebook_ids: Some(vec![private]),
1011 };
1012 assert_eq!(
1013 get(knowledge.clone(), Some(note.id), None, None)
1014 .await
1015 .unwrap()
1016 .version,
1017 1
1018 );
1019 assert!(get(
1020 Access::Knowledge {
1021 owner_id: 1,
1022 notebook_ids: Some(vec![])
1023 },
1024 Some(note.id),
1025 None,
1026 None
1027 )
1028 .await
1029 .is_err());
1030 assert_eq!(
1031 list(
1032 Access::Public,
1033 NoteFilter {
1034 query: "100%_".into(),
1035 ..Default::default()
1036 }
1037 )
1038 .await
1039 .unwrap()
1040 .total,
1041 1
1042 );
1043 assert_eq!(
1044 list(
1045 Access::Public,
1046 NoteFilter {
1047 query: "第二版".into(),
1048 ..Default::default()
1049 }
1050 )
1051 .await
1052 .unwrap()
1053 .total,
1054 0
1055 );
1056 assert_eq!(
1057 list(
1058 Access::Public,
1059 NoteFilter {
1060 notebook_id: Some(private),
1061 ..Default::default()
1062 }
1063 )
1064 .await
1065 .unwrap()
1066 .total,
1067 0
1068 );
1069 act(1, note.id, 2, NoteAction::Unpublish, None).await.unwrap();
1070 assert!(get(Access::Public, Some(note.id), None, None)
1071 .await
1072 .is_err());
1073 assert!(get_public(note.slug.as_str()).await.is_err());
1075 assert!(get(knowledge.clone(), Some(note.id), None, None)
1076 .await
1077 .is_ok());
1078 act(1, note.id, 2, NoteAction::Trash, None).await.unwrap();
1079 assert!(get(knowledge, Some(note.id), None, None).await.is_err());
1080 assert_eq!(
1081 list(
1082 Access::Owner(1),
1083 NoteFilter {
1084 trash: true,
1085 ..Default::default()
1086 }
1087 )
1088 .await
1089 .unwrap()
1090 .total,
1091 1
1092 );
1093 act(1, note.id, 2, NoteAction::Restore, None).await.unwrap();
1094 assert!(
1095 get(Access::Public, Some(note.id), None, None)
1096 .await
1097 .is_err(),
1098 "restore never republishes"
1099 );
1100 assert_eq!(history(1, note.id).await.unwrap().len(), 2);
1101 let mut old = get(Access::Owner(1), Some(note.id), None, Some(1))
1102 .await
1103 .unwrap()
1104 .draft();
1105 old.expected_version = Some(2);
1106 assert_eq!(
1107 save(1, old, None).await.unwrap().version,
1108 3,
1109 "restoration creates a new version"
1110 );
1111 assert!(act(2, note.id, 3, NoteAction::Publish, None).await.is_err());
1112 reorder(1, public, vec![note.id]).await.unwrap();
1113 assert!(reorder(2, public, vec![note.id]).await.is_err());
1114
1115 let image_id=uuid::Uuid::new_v4();
1117 client.execute("INSERT INTO note_attachments(id,owner_id,mime,data) VALUES($1,1,'image/png',$2)", &[&image_id, &vec![1u8,2,3]]).await.unwrap();
1118 let media_status=|| super::super::attachments::read(axum::http::HeaderMap::new(),axum::extract::Path(image_id.to_string()));
1119 assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND);
1120 let reference_only=save(1,NoteDraft {content_md:format!("`/note-media/{image_id}`\n\n"),..Default::default()},None).await.unwrap();
1121 act(1, reference_only.id, 1, NoteAction::Publish, None).await.unwrap();
1122 assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND,"code examples and remote URLs cannot publish local media");
1123 let draft=NoteDraft {content_md:format!(""),..Default::default()};
1124 assert!(save(2,draft.clone(),None).await.is_err(),"cannot reuse someone else's private attachment");
1125 let image_note=save(1,draft,None).await.unwrap();
1126 act(1, image_note.id, 1, NoteAction::IncludeKnowledge, None).await.unwrap();
1127 assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND,"knowledge enrollment is not public access");
1128 act(1, image_note.id, 1, NoteAction::Publish, None).await.unwrap();
1129 let response=media_status().await;
1130 assert_eq!(response.status(),axum::http::StatusCode::OK);
1131 assert_eq!(response.headers()["cache-control"],"private, no-store");
1132 let mut draft=image_note.draft();draft.content_md="草稿移除了图片,公开版仍保留".into();
1133 save(1,draft,None).await.unwrap();
1134 assert_eq!(media_status().await.status(),axum::http::StatusCode::OK);
1135 act(1, image_note.id, 2, NoteAction::Unpublish, None).await.unwrap();
1136 assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND);
1137
1138 let asset_id=uuid::Uuid::new_v4();
1140 let path=format!("2026/09/20/{asset_id}.webp");
1141 client.execute("INSERT INTO assets(id,path,filename,mime,size_bytes,width,height) VALUES($1,$2,$3,'image/webp',3,1,1)",&[&asset_id,&path,&asset_id.to_string()]).await.unwrap();
1142 let asset_note=save(1,NoteDraft {content_md:format!(""),..Default::default()},None).await.unwrap();
1143 let mut draft=asset_note.draft();draft.content_md="新草稿不再使用图片".into();
1144 save(1,draft,None).await.unwrap();
1145 act(1, asset_note.id, 2, NoteAction::Trash, None).await.unwrap();
1146 let assets=crate::api::assets::list::list_assets_impl(crate::models::asset::AssetFilter::Used,asset_id.to_string(),crate::models::asset::AssetSort::CreatedDesc,1).await.unwrap();
1147 assert_eq!(assets.total,1);
1148 assert_eq!(assets.assets[0].ref_count,1);
1149 assert!(matches!(&assets.assets[0].refs[0],crate::models::asset::AssetRef::Note {note_id,..} if *note_id==asset_note.id));
1150 assert!(!crate::api::assets::delete::delete_asset_impl(asset_id.to_string()).await.unwrap().success);
1151
1152 let filtered=save(1,NoteDraft {title:"状态筛选验收".into(),content_md:"正文".into(),notebook_ids:vec![public],..Default::default()},None).await.unwrap();
1153 let count=|scope,published,knowledge|list(scope,NoteFilter {query:"状态筛选验收".into(),published,knowledge,..Default::default()});
1154 assert_eq!(count(Access::Owner(1),Some(false),Some(false)).await.unwrap().total,1);
1155 assert_eq!(count(Access::Owner(1),Some(true),None).await.unwrap().total,0);
1156 act(1, filtered.id, 1, NoteAction::Publish, None).await.unwrap();
1157 assert_eq!(count(Access::Owner(1),Some(true),Some(false)).await.unwrap().total,1);
1158 act(1, filtered.id, 1, NoteAction::IncludeKnowledge, None).await.unwrap();
1159 assert_eq!(count(Access::Owner(1),Some(true),Some(true)).await.unwrap().total,1);
1160 assert_eq!(count(Access::Owner(1),None,Some(false)).await.unwrap().total,0);
1161 assert_eq!(count(Access::Public,Some(false),Some(false)).await.unwrap().total,1,"public endpoint ignores private state probes");
1162 assert!(archive_notebook(2,public,true).await.is_err());
1163 archive_notebook(1,public,true).await.unwrap();
1164 assert!(notebooks(Access::Public).await.unwrap().iter().all(|b|b.id!=public));
1165 let archived=notebooks(Access::Owner(1)).await.unwrap().into_iter().find(|b|b.id==public).unwrap();
1166 assert!(archived.archived_at.is_some());assert!(!archived.is_public);
1167 assert!(get(Access::Public,Some(filtered.id),None,None).await.is_ok(),"archiving directory does not retract published notes");
1168 let knowledge=Access::Knowledge {owner_id:1,notebook_ids:Some(vec![public])};
1169 assert!(get(knowledge.clone(),Some(filtered.id),None,None).await.is_ok(),"archiving does not silently revoke grants");
1170 assert!(notebooks(knowledge).await.unwrap().is_empty());
1171 assert!(save_notebook(1,NotebookInput {id:Some(public),title:"归档目录".into(),is_public:true,..Default::default()}).await.is_err());
1172 archive_notebook(1,public,false).await.unwrap();
1173 let restored=notebooks(Access::Owner(1)).await.unwrap().into_iter().find(|b|b.id==public).unwrap();
1174 assert!(restored.archived_at.is_none());assert!(!restored.is_public,"restore requires explicit re-publication");
1175 assert_eq!(get(Access::Owner(1),Some(filtered.id),None,None).await.unwrap().notebook_ids,vec![public]);
1176 });
1177 }
1178
1179 #[test]
1180 #[ignore = "requires disposable DATABASE_URL database ygg_notes_test"]
1181 fn notes_database_context_workflow() {
1182 crate::db::TEST_DATABASE_RUNTIME.block_on(async {
1183 let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
1184 let mut client = get_conn().await.unwrap();
1185 let database: String = client
1186 .query_one("SELECT current_database()", &[])
1187 .await
1188 .unwrap()
1189 .get(0);
1190 assert_eq!(
1191 database, "ygg_notes_test",
1192 "requires isolated test database"
1193 );
1194 crate::db::migrate::run_on_conn(&mut client).await.unwrap();
1195 client
1196 .batch_execute(
1197 "TRUNCATE users CASCADE;
1198 INSERT INTO users(id,username,email,password_hash,role) VALUES
1199 (1,'note-owner','note@test.invalid','unused','admin');",
1200 )
1201 .await
1202 .unwrap();
1203 for title in ["甲", "乙", "丙"] {
1204 let input = NotebookInput {
1205 title: title.into(),
1206 is_public: true,
1207 ..Default::default()
1208 };
1209 save_notebook(1, input).await.unwrap();
1210 }
1211 let books = notebooks(Access::Owner(1)).await.unwrap();
1212 let book = |title: &str| books.iter().find(|b| b.title == title).unwrap().id;
1213 let (a, b, c) = (book("甲"), book("乙"), book("丙"));
1214 let mut ids = Vec::new();
1215 for (title, notebook_ids, publish) in [
1216 ("第一篇", vec![a, b], true),
1217 ("草稿", vec![a], false),
1218 ("第二篇", vec![a, c], true),
1219 ("第三篇", vec![a], true),
1220 ] {
1221 let draft = NoteDraft {
1222 title: title.into(),
1223 kind: NoteKind::Topic,
1224 content_md: format!("## 小节\n\n{title}"),
1225 notebook_ids,
1226 ..Default::default()
1227 };
1228 let note = save(1, draft, None).await.unwrap();
1229 if publish {
1230 act(1, note.id, 1, NoteAction::Publish, None).await.unwrap();
1231 }
1232 ids.push(note);
1233 }
1234 reorder(1, a, ids.iter().map(|n| n.id).collect())
1235 .await
1236 .unwrap();
1237 archive_notebook(1, c, true).await.unwrap();
1238 let slug = |i: usize| ids[i].slug.clone();
1239
1240 assert!(context(&slug(0), None).await.unwrap().is_none());
1242 let first = context(&slug(0), Some(a)).await.unwrap().unwrap();
1243 assert_eq!(first.notebook.id, a);
1244 let titles: Vec<_> = first.siblings.iter().map(|s| s.title.as_str()).collect();
1245 assert_eq!(
1246 titles,
1247 ["第一篇", "第二篇", "第三篇"],
1248 "unpublished siblings are excluded"
1249 );
1250 assert_eq!((first.position, first.total), (1, 3));
1251 assert!(first.prev.is_none());
1252 assert_eq!(first.next.as_ref().unwrap().slug, slug(2));
1253 let second = context(&slug(2), Some(c)).await.unwrap().unwrap();
1255 assert_eq!(second.notebook.id, a);
1256 let last = context(&slug(3), None).await.unwrap().unwrap();
1257 assert_eq!(last.position, 3);
1258 assert!(last.next.is_none());
1259 assert_eq!(last.prev.as_ref().unwrap().slug, slug(2));
1260 assert!(context(&slug(1), Some(a)).await.unwrap().is_none());
1262
1263 let listed = list(Access::Public, NoteFilter::default()).await.unwrap();
1264 assert!(listed
1265 .notes
1266 .iter()
1267 .all(|n| n.sections == 1 && n.toc_html.is_empty()));
1268 let stats = stats(8).await.unwrap();
1269 assert_eq!((stats.moments, stats.topics), (0, 3));
1270 });
1271 }
1272}