add: journal note tags and directories

This commit is contained in:
trisua 2025-06-21 19:44:28 -04:00
parent a37312fecf
commit af6fbdf04e
16 changed files with 722 additions and 78 deletions

View file

@ -244,3 +244,9 @@ version = "1.0.0"
"journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar." "journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar."
"journals:label.editor" = "Editor" "journals:label.editor" = "Editor"
"journals:label.preview_pane" = "Preview" "journals:label.preview_pane" = "Preview"
"journals:action.edit_tags" = "Edit tags"
"journals:action.tags" = "Tags"
"journals:label.directories" = "Directories"
"journals:action.create_subdir" = "Create subdirectory"
"journals:action.create_root_dir" = "Create root directory"
"journals:action.move" = "Move"

View file

@ -1065,14 +1065,14 @@ details summary::-webkit-details-marker {
display: none; display: none;
} }
details[open] summary { details[open] > summary {
position: relative; position: relative;
color: var(--color-primary); color: var(--color-text-lowered) !important;
background: var(--color-super-lowered); background: var(--color-super-lowered) !important;
margin-bottom: var(--pad-1); margin-bottom: var(--pad-1);
} }
details[open] summary::after { details[open] > summary::after {
top: 0; top: 0;
left: 0; left: 0;
width: 5px; width: 5px;

View file

@ -2028,6 +2028,22 @@
(str (text "general:action.delete"))))) (str (text "general:action.delete")))))
(text "{%- endif %}")) (text "{%- endif %}"))
(text "{% if selected_note -%}")
; open all details elements above the selected note
(script
("defer" "true")
(text "setTimeout(() => {
let cursor = document.querySelector(\"[ui_ident=active_note]\");
while (cursor) {
if (cursor.nodeName === \"DETAILS\") {
cursor.setAttribute(\"open\", \"true\");
}
cursor = cursor.parentElement;
}
}, 150);"))
(text "{%- endif %}")
(div (div
("class" "flex flex-col gap-2") ("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
@ -2041,9 +2057,39 @@
(text "{%- endif %}") (text "{%- endif %}")
; note listings ; note listings
(text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}") (text "{{ self::notes_list_dir_listing_inner(dir=[0, 0, \"root\"], dirs=journal.dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}"))
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
(details
(summary
("class" "button w-full justify-start raised w-full")
(icon (text "folder"))
(text "{{ dir[2] }}"))
(div
("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
(text "{{ self::notes_list_dir_listing_inner(dir=dir, dirs=dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}")))
(text "{%- endmacro %}")
(text "{% macro notes_list_dir_listing_inner(dir, dirs, notes, owner, journal, view_mode=false) -%}")
; child dirs
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
(text "{{ self::notes_list_dir_listing(dir=subdir, dirs=dirs, notes=notes, owner=owner, journal=journal) }}")
(text "{%- endif %} {% endfor %}")
; child notes
(text "{% for note in notes %} {% if note.dir == dir[0] -%} {% if not view_mode or note.title != \"journal.css\" -%}")
(text "{{ self::notes_list_note_listing(note=note, owner=owner, journal=journal) }}")
(text "{%- endif %} {%- endif %} {% endfor %}")
(text "{%- endmacro %}")
(text "{% macro notes_list_note_listing(owner, journal, note) -%}")
(div (div
("class" "flex flex-row gap-1") ("class" "flex flex-row gap-1")
("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
(a (a
("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}")
("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") ("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
@ -2065,12 +2111,101 @@
("onclick" "change_note_title('{{ note.id }}')") ("onclick" "change_note_title('{{ note.id }}')")
(icon (text "pencil")) (icon (text "pencil"))
(str (text "chats:action.rename"))) (str (text "chats:action.rename")))
(a
("class" "button")
("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags")
(icon (text "tag"))
(str (text "journals:action.edit_tags")))
(button
("class" "button")
("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()")
(icon (text "brush-cleaning"))
(str (text "journals:action.move")))
(button (button
("onclick" "delete_note('{{ note.id }}')") ("onclick" "delete_note('{{ note.id }}')")
("class" "red") ("class" "red")
(icon (text "trash")) (icon (text "trash"))
(str (text "general:action.delete"))))) (str (text "general:action.delete")))))
(text "{%- endif %}")) (text "{%- endif %}"))
(text "{%- endif %} {% endfor %}")) (text "{%- endmacro %}")
(text "{%- endif %}")
(text "{% macro note_tags(note) -%} {% if note and note.tags|length > 0 -%}")
(div
("class" "flex gap-1 flex-wrap")
(text "{% for tag in note.tags %}")
(a
("href" "{% if view_mode -%} /@{{ owner.username }} {%- else -%} /@{{ user.username }} {%- endif -%} /{{ journal.title }}?tag={{ tag }}")
("class" "notification chip")
(span (text "{{ tag }}")))
(text "{% endfor %}"))
(text "{%- endif %} {%- endmacro %}")
(text "{% macro directories_editor(dirs) -%}")
(button
("onclick" "create_directory('0')")
(icon (text "plus"))
(str (text "journals:action.create_root_dir")))
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
(text "{{ self::directories_editor_listing(dir=dir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}")
(text "{%- endmacro %}")
(text "{% macro directories_editor_listing(dir, dirs) -%}")
(div
("class" "flex flex-row gap-1")
(button
("class" "justify-start lowered w-full")
(icon (text "folder-open"))
(text "{{ dir[2] }}"))
(div
("class" "dropdown")
(button
("class" "big_icon lowered")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(button
("onclick" "create_directory('{{ dir[0] }}')")
(icon (text "plus"))
(str (text "journals:action.create_subdir")))
(button
("onclick" "delete_directory('{{ dir[0] }}')")
("class" "red")
(icon (text "trash"))
(str (text "general:action.delete"))))))
(div
("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
; subdir listings
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
(text "{{ self::directories_editor_listing(dir=subdir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}"))
(text "{%- endmacro %}")
(text "{% macro note_mover_dirs(dirs) -%}")
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
(text "{{ self::note_mover_dirs_listing(dir=dir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}")
(text "{%- endmacro %}")
(text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
(button
("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
("class" "justify-start lowered w-full")
(icon (text "folder-open"))
(text "{{ dir[2] }}"))
(div
("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
; subdir listings
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
(text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}"))
(text "{%- endmacro %}") (text "{%- endmacro %}")

View file

@ -82,6 +82,8 @@
(text "{% if journal -%}") (text "{% if journal -%}")
(div (div
("class" "mobile_nav w-full flex items-center justify-between gap-2") ("class" "mobile_nav w-full flex items-center justify-between gap-2")
(div
("class" "flex flex-col gap-2")
(div (div
("class" "flex gap-2 items-center") ("class" "flex gap-2 items-center")
(a (a
@ -99,7 +101,7 @@
(text "{% if note -%}") (text "{% if note -%}")
(span (text "/")) (span (text "/"))
(b (text "{{ note.title }}")) (b (text "{{ note.title }}"))
(text "{%- endif %}")) (text "{%- endif %}")))
(text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}") (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
(div (div
@ -196,10 +198,29 @@
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))))) (text "{{ text \"general:action.save\" }}")))))))
; users should also be able to manage the journal's sub directories here
(details
("class" "w-full")
(summary
("class" "button lowered w-full justify-start")
(icon (text "folders"))
(str (text "journals:label.directories")))
(div
("class" "card flex flex-col gap-2 lowered")
(text "{{ components::directories_editor(dirs=journal.dirs) }}")))
(text "{% else %}") (text "{% else %}")
; we're in view mode; just show journal listing and notes as journal homepage ; we're in view mode; just show journal listing and notes as journal homepage
(div (div
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
(text "{% if tag|length > 0 -%}")
(a
("href" "?")
("class" "notification chip w-content")
(text "{{ tag }}"))
(text "{%- endif %}")
(text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}")) (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}"))
(text "{%- endif %}") (text "{%- endif %}")
(text "{% else %}") (text "{% else %}")
@ -252,26 +273,82 @@
("href" "#/preview") ("href" "#/preview")
("data-tab-button" "preview") ("data-tab-button" "preview")
("data-turbo" "false") ("data-turbo" "false")
(str (text "journals:label.preview_pane")))) (str (text "journals:label.preview_pane")))
(a
("href" "#/tags")
("data-tab-button" "tags")
("data-turbo" "false")
("class" "hidden")
(str (text "journals:action.edit_tags"))))
(text "{%- endif %}") (text "{%- endif %}")
; tabs ; tabs
(text "{{ components::note_tags(note=note) }}")
(div (div
("data-tab" "editor") ("data-tab" "editor")
(div
("class" "flex flex-col gap-2 card") ("class" "flex flex-col gap-2 card")
("style" "animation: fadein ease-in-out 1 0.5s forwards running") ("style" "animation: fadein ease-in-out 1 0.5s forwards running")
("id" "editor_tab")) ("id" "editor_tab"))
(button
("onclick" "change_note_content('{{ note.id }}')")
(icon (text "check"))
(str (text "general:action.save"))))
(div (div
("data-tab" "preview") ("data-tab" "preview")
("class" "flex flex-col gap-2 card hidden") ("class" "flex flex-col gap-2 card hidden")
("style" "animation: fadein ease-in-out 1 0.5s forwards running") ("style" "animation: fadein ease-in-out 1 0.5s forwards running")
("id" "preview_tab")) ("id" "preview_tab"))
(div
("data-tab" "tags")
("class" "flex flex-col gap-2 card hidden")
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
(form
("onsubmit" "save_tags(event)")
("class" "flex flex-col gap-1")
(label
("for" "tags")
(str (text "journals:action.tags"))
(textarea
("type" "text")
("name" "tags")
("id" "tags")
("placeholder" "tags")
("required" "")
("minlength" "2")
("maxlength" "128")
(text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))
(span ("class" "fade") (text "Tags should be separated by a comma.")))
(button (button
("onclick" "change_note_content('{{ note.id }}')")
(icon (text "check")) (icon (text "check"))
(str (text "general:action.save"))) (str (text "general:action.save"))))
(script
(text "globalThis.save_tags = (e) => {
event.preventDefault();
fetch(\"/api/v1/notes/{{ selected_note }}/tags\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
tags: e.target.tags.value.split(\",\").map(t => t.trim()).filter(t => t),
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}")))
; init codemirror ; init codemirror
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
@ -281,6 +358,7 @@
document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" }); document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
} }
document.getElementById(\"editor_tab\").innerHTML = \"\";
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), { globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
value: document.getElementById(\"editor_content\").innerHTML, value: document.getElementById(\"editor_content\").innerHTML,
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\", mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
@ -349,7 +427,11 @@
(div (div
("class" "flex w-full justify-between gap-2") ("class" "flex w-full justify-between gap-2")
(div
("class" "flex flex-col gap-2")
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
(text "{{ components::note_tags(note=note) }}"))
(text "{% if user and user.id == owner.id -%}") (text "{% if user and user.id == owner.id -%}")
(button (button
("class" "small") ("class" "small")
@ -600,6 +682,99 @@
}); });
} }
globalThis.create_directory = async (parent) => {
const name = await trigger(\"atto::prompt\", [\"Directory name:\"]);
if (!name) {
return;
}
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
name,
parent,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
globalThis.delete_directory = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This will delete all notes within this directory.\",
]))
) {
return;
}
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
id,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
fetch(`/api/v1/notes/{{ selected_journal }}/dir/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
});
}
globalThis.move_note_dir = async (id, dir) => {
fetch(`/api/v1/notes/${id}/dir`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
dir,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
// sidebars // sidebars
window.SIDEBARS_OPEN = false; window.SIDEBARS_OPEN = false;
if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") { if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
@ -642,4 +817,24 @@
notes_list.style.left = \"0\"; notes_list.style.left = \"0\";
} }
}"))) }")))
(text "{% if journal -%}")
; note mover
(dialog
("id" "note_mover_dialog")
(div
("class" "inner flex flex-col gap-2")
(p (text "Select a directory to move this note into:"))
(text "{{ components::note_mover_dirs(dirs=journal.dirs) }}")
(div
("class" "flex justify-between")
(div)
(div
("class" "flex gap-2")
(button
("class" "bold red lowered")
("onclick" "document.getElementById('note_mover_dialog').close()")
("type" "button")
(text "{{ icon \"x\" }} {{ text \"dialog:action.close\" }}"))))))
(text "{%- endif %}")
(text "{% endblock %}") (text "{% endblock %}")

View file

@ -4,9 +4,12 @@ use axum::{
Extension, Extension,
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_shared::snow::Snowflake;
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle}, routes::api::v1::{
AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle,
},
State, State,
}; };
use tetratto_core::{ use tetratto_core::{
@ -198,3 +201,86 @@ pub async fn delete_request(
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }
pub async fn add_dir_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<AddJournalDir>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if props.name.len() > 32 {
return Json(Error::DataTooLong("name".to_string()).into());
}
let mut journal = match data.get_journal_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// add dir
journal.dirs.push((
Snowflake::new().to_string().parse::<usize>().unwrap(),
match props.parent.parse() {
Ok(p) => p,
Err(_) => return Json(Error::Unknown.into()),
},
props.name,
));
// ...
match data.update_journal_dirs(id, &user, journal.dirs).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Journal updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn remove_dir_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<RemoveJournalDir>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let mut journal = match data.get_journal_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// add dir
let dir_id: usize = match props.dir.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
};
journal
.dirs
.remove(match journal.dirs.iter().position(|x| x.0 == dir_id) {
Some(idx) => idx,
None => return Json(Error::GeneralNotFound("directory".to_string()).into()),
});
// ...
match data.update_journal_dirs(id, &user, journal.dirs).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Journal updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -570,14 +570,22 @@ pub fn routes() -> Router {
"/journals/{id}/privacy", "/journals/{id}/privacy",
post(journals::update_privacy_request), post(journals::update_privacy_request),
) )
.route("/journals/{id}/dirs", post(journals::add_dir_request))
.route("/journals/{id}/dirs", delete(journals::remove_dir_request))
// notes // notes
.route("/notes", post(notes::create_request)) .route("/notes", post(notes::create_request))
.route("/notes/{id}", get(notes::get_request)) .route("/notes/{id}", get(notes::get_request))
.route("/notes/{id}", delete(notes::delete_request)) .route("/notes/{id}", delete(notes::delete_request))
.route("/notes/{id}/title", post(notes::update_title_request)) .route("/notes/{id}/title", post(notes::update_title_request))
.route("/notes/{id}/content", post(notes::update_content_request)) .route("/notes/{id}/content", post(notes::update_content_request))
.route("/notes/{id}/dir", post(notes::update_dir_request))
.route("/notes/{id}/tags", post(notes::update_tags_request))
.route("/notes/from_journal/{id}", get(notes::list_request)) .route("/notes/from_journal/{id}", get(notes::list_request))
.route("/notes/preview", post(notes::render_markdown_request)) .route("/notes/preview", post(notes::render_markdown_request))
.route(
"/notes/{journal}/dir/{dir}",
delete(notes::delete_by_dir_request),
)
// uploads // uploads
.route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", get(uploads::get_request))
.route("/uploads/{id}", delete(uploads::delete_request)) .route("/uploads/{id}", delete(uploads::delete_request))
@ -926,3 +934,24 @@ pub struct CreateMessageReaction {
pub message: String, pub message: String,
pub emoji: String, pub emoji: String,
} }
#[derive(Deserialize)]
pub struct UpdateNoteDir {
pub dir: String,
}
#[derive(Deserialize)]
pub struct AddJournalDir {
pub name: String,
#[serde(default)]
pub parent: String,
}
#[derive(Deserialize)]
pub struct RemoveJournalDir {
pub dir: String,
}
#[derive(Deserialize)]
pub struct UpdateNoteTags {
pub tags: Vec<String>,
}

View file

@ -7,7 +7,10 @@ use axum_extra::extract::CookieJar;
use tetratto_shared::unix_epoch_timestamp; use tetratto_shared::unix_epoch_timestamp;
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle}, routes::api::v1::{
CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags,
UpdateNoteTitle,
},
State, State,
}; };
use tetratto_core::{ use tetratto_core::{
@ -222,8 +225,96 @@ pub async fn delete_request(
} }
} }
pub async fn delete_by_dir_request(
jar: CookieJar,
Path((journal, id)): Path<(usize, usize)>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_notes_by_journal_dir(journal, id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Notes deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse { pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content)) tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
.replace("\\@", "@") .replace("\\@", "@")
.replace("%5C@", "@") .replace("%5C@", "@")
} }
pub async fn update_dir_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateNoteDir>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let note = match data.get_note_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
let journal = match data.get_journal_by_id(note.journal).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// make sure dir exists
let dir = match props.dir.parse::<usize>() {
Ok(d) => d,
Err(_) => return Json(Error::Unknown.into()),
};
if dir != 0 {
if journal.dirs.iter().find(|x| x.0 == dir).is_none() {
return Json(Error::GeneralNotFound("directory".to_string()).into());
}
}
// ...
match data.update_note_dir(id, &user, dir as i64).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_tags_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateNoteTags>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_note_tags(id, &user, props.tags).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -158,14 +158,6 @@ pub async fn view_request(
} }
} }
// ...
let notes = match data.0.get_notes_by_journal(journal.id).await {
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
// ... // ...
let note = if !selected_note.is_empty() { let note = if !selected_note.is_empty() {
match data match data
@ -199,7 +191,7 @@ pub async fn view_request(
context.insert("note", &note); context.insert("note", &note);
context.insert("owner", &owner); context.insert("owner", &owner);
context.insert("notes", &notes); context.insert::<[i8; 0], &str>("notes", &[]);
context.insert("view_mode", &true); context.insert("view_mode", &true);
context.insert("is_editor", &false); context.insert("is_editor", &false);
@ -213,6 +205,7 @@ pub async fn index_view_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path((owner, selected_journal)): Path<(String, String)>, Path((owner, selected_journal)): Path<(String, String)>,
Query(props): Query<JournalsAppQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = data.read().await; let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) { let user = match get_user_from_token!(jar, data.0) {
@ -257,11 +250,24 @@ pub async fn index_view_request(
} }
// ... // ...
let notes = match data.0.get_notes_by_journal(journal.id).await { let notes = if props.tag.is_empty() {
match data.0.get_notes_by_journal(journal.id).await {
Ok(p) => Some(p), Ok(p) => Some(p),
Err(e) => { Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await)); return Err(Html(render_error(e, &jar, &data, &user).await));
} }
}
} else {
match data
.0
.get_notes_by_journal_tag(journal.id, &props.tag)
.await
{
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
}
}; };
let lang = get_lang!(jar, data.0); let lang = get_lang!(jar, data.0);
@ -281,6 +287,7 @@ pub async fn index_view_request(
context.insert("view_mode", &true); context.insert("view_mode", &true);
context.insert("is_editor", &false); context.insert("is_editor", &false);
context.insert("tag", &props.tag);
// return // return
Ok(Html(data.1.render("journals/app.html", &context).unwrap())) Ok(Html(data.1.render("journals/app.html", &context).unwrap()))

View file

@ -196,4 +196,6 @@ pub struct RepostsQuery {
pub struct JournalsAppQuery { pub struct JournalsAppQuery {
#[serde(default)] #[serde(default)]
pub view: bool, pub view: bool,
#[serde(default)]
pub tag: String,
} }

View file

@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS journals (
created BIGINT NOT NULL, created BIGINT NOT NULL,
owner BIGINT NOT NULL, owner BIGINT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
privacy TEXT NOT NULL privacy TEXT NOT NULL,
dirs TEXT NOT NUll
) )

View file

@ -5,5 +5,7 @@ CREATE TABLE IF NOT EXISTS notes (
title TEXT NOT NULL, title TEXT NOT NULL,
journal BIGINT NOT NULL, journal BIGINT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
edited BIGINT NOT NULL edited BIGINT NOT NULL,
dir BIGINT NOT NULL,
tags TEXT NOT NULL
) )

View file

@ -20,6 +20,7 @@ impl DataManager {
owner: get!(x->2(i64)) as usize, owner: get!(x->2(i64)) as usize,
title: get!(x->3(String)), title: get!(x->3(String)),
privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), privacy: serde_json::from_str(&get!(x->4(String))).unwrap(),
dirs: serde_json::from_str(&get!(x->5(String))).unwrap(),
} }
} }
@ -128,13 +129,14 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO journals VALUES ($1, $2, $3, $4, $5)", "INSERT INTO journals VALUES ($1, $2, $3, $4, $5, $6)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
&(data.owner as i64), &(data.owner as i64),
&data.title, &data.title,
&serde_json::to_string(&data.privacy).unwrap(), &serde_json::to_string(&data.privacy).unwrap(),
&serde_json::to_string(&data.dirs).unwrap(),
] ]
); );
@ -183,4 +185,5 @@ impl DataManager {
auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}");
auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
} }

View file

@ -15,6 +15,8 @@ impl DataManager {
journal: get!(x->4(i64)) as usize, journal: get!(x->4(i64)) as usize,
content: get!(x->5(String)), content: get!(x->5(String)),
edited: get!(x->6(i64)) as usize, edited: get!(x->6(i64)) as usize,
dir: get!(x->7(i64)) as usize,
tags: serde_json::from_str(&get!(x->8(String))).unwrap(),
} }
} }
@ -65,6 +67,31 @@ impl DataManager {
Ok(res.unwrap()) Ok(res.unwrap())
} }
/// Get all notes by journal with the given tag.
///
/// # Arguments
/// * `id` - the ID of the journal to fetch notes for
/// * `tag`
pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> Result<Vec<Note>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM notes WHERE journal = $1 AND tags::jsonb ? $2 ORDER BY edited DESC",
params![&(id as i64), tag],
|x| { Self::get_note_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("note".to_string()));
}
Ok(res.unwrap())
}
const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10; const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
/// Create a new note in the database. /// Create a new note in the database.
@ -137,7 +164,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7)", "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -146,6 +173,8 @@ impl DataManager {
&(data.journal as i64), &(data.journal as i64),
&data.content, &data.content,
&(data.edited as i64), &(data.edited as i64),
&(data.dir as i64),
&serde_json::to_string(&data.tags).unwrap(),
] ]
); );
@ -181,7 +210,45 @@ impl DataManager {
Ok(()) Ok(())
} }
/// Delete all notes by dir ID.
///
/// # Arguments
/// * `journal`
/// * `dir`
pub async fn delete_notes_by_journal_dir(
&self,
journal: usize,
dir: usize,
user: &User,
) -> Result<()> {
let journal = self.get_journal_by_id(journal).await?;
if journal.owner != user.id && !user.permissions.check(FinePermission::MANAGE_NOTES) {
return Err(Error::NotAllowed);
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"DELETE FROM notes WHERE dir = $1 AND journal = $2 ORDER BY edited DESC",
&[&(dir as i64), &(journal.id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(())
}
auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
auto_method!(update_note_tags(Vec<String>)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.note:{}");
auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
} }

View file

@ -22,6 +22,10 @@ pub struct Journal {
pub owner: usize, pub owner: usize,
pub title: String, pub title: String,
pub privacy: JournalPrivacyPermission, pub privacy: JournalPrivacyPermission,
/// An array of directories notes can be placed in.
///
/// `Vec<(id, parent id, name)>`
pub dirs: Vec<(usize, usize, String)>,
} }
impl Journal { impl Journal {
@ -33,6 +37,7 @@ impl Journal {
owner, owner,
title, title,
privacy: JournalPrivacyPermission::default(), privacy: JournalPrivacyPermission::default(),
dirs: Vec::new(),
} }
} }
} }
@ -49,6 +54,12 @@ pub struct Note {
pub journal: usize, pub journal: usize,
pub content: String, pub content: String,
pub edited: usize, pub edited: usize,
/// The "id" of the directoryy this note is in.
///
/// Directories are held in the journal in the `dirs` column.
pub dir: usize,
/// An array of tags associated with the note.
pub tags: Vec<String>,
} }
impl Note { impl Note {
@ -64,6 +75,8 @@ impl Note {
journal, journal,
content, content,
edited: created, edited: created,
dir: 0,
tags: Vec::new(),
} }
} }
} }

View file

@ -0,0 +1,2 @@
ALTER TABLE journals
ADD COLUMN dirs TEXT NOT NULL DEFAULT '[]';

View file

@ -0,0 +1,5 @@
ALTER TABLE notes
ADD COLUMN dir BIGINT NOT NULL DEFAULT 0;
ALTER TABLE notes
ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';