diff --git a/services/jtype-core/src/lib.rs b/services/jtype-core/src/lib.rs index 6302137..f6ba656 100644 --- a/services/jtype-core/src/lib.rs +++ b/services/jtype-core/src/lib.rs @@ -1349,6 +1349,8 @@ pub struct BoardCardInfo { pub task_total: i64, pub icon: Option, pub excerpt: Option, + /// Attachment URLs/paths (frontmatter `attachments`, comma-separated). + pub attachments: Vec, /// Full frontmatter key/values, so the board can surface user-defined custom /// fields declared in the `.board` config without re-reading the file. pub properties: HashMap, @@ -1360,6 +1362,16 @@ pub struct BoardCardInfo { pub relates: Vec, } +/// Parse a frontmatter `attachments` value (comma-separated URLs/paths) into a +/// list. Unlike tags, values are kept verbatim (no `#`/`[]` stripping). +fn parse_attachments(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + /// The body content after the frontmatter block (for previews/excerpts). fn body_after_frontmatter(content: &str) -> &str { let normalized = content.strip_prefix('\u{feff}').unwrap_or(content); @@ -1508,6 +1520,7 @@ fn scan_board_cards_inner( task_total, icon: fm.get("icon").cloned().filter(|v| !v.is_empty()), excerpt: body_excerpt(&content), + attachments: fm.get("attachments").map(|v| parse_attachments(v)).unwrap_or_default(), properties: fm.clone(), blocked_by: fm.get("blocked_by").map(|v| parse_card_links(v)).unwrap_or_default(), blocks: fm.get("blocks").map(|v| parse_card_links(v)).unwrap_or_default(), diff --git a/services/jtype-web/frontend/src/pages/Kanban.tsx b/services/jtype-web/frontend/src/pages/Kanban.tsx index c84630c..0fc4bef 100644 --- a/services/jtype-web/frontend/src/pages/Kanban.tsx +++ b/services/jtype-web/frontend/src/pages/Kanban.tsx @@ -143,6 +143,9 @@ export function Kanban() { taskDone: tasks.done, taskTotal: tasks.total, excerpt: bodyExcerpt(c.description ?? ''), + attachments: c.propertiesExtra && typeof c.propertiesExtra === 'object' && Array.isArray((c.propertiesExtra as Record).attachments) + ? ((c.propertiesExtra as Record).attachments as unknown[]).filter((x): x is string => typeof x === 'string') + : [], custom: pickCustomFields(propsStr, view.fields), } }) @@ -252,12 +255,16 @@ export function Kanban() { if (patch.due !== undefined) body.dueAt = patch.due ? `${patch.due} 00:00:00` : null if (patch.tags !== undefined) body.labelIds = patch.tags.map(t => t.id).filter(Boolean) as string[] if (patch.notes !== undefined) body.description = patch.notes - if (patch.icon !== undefined || patch.custom !== undefined) { + if (patch.icon !== undefined || patch.attachments !== undefined || patch.custom !== undefined) { const cur = raw.propertiesExtra && typeof raw.propertiesExtra === 'object' ? { ...(raw.propertiesExtra as Record) } : {} if (patch.icon !== undefined) { if (patch.icon) cur.icon = patch.icon else delete cur.icon } + if (patch.attachments !== undefined) { + if (patch.attachments.length) cur.attachments = patch.attachments + else delete cur.attachments + } if (patch.custom !== undefined) for (const [k, v] of Object.entries(patch.custom)) { if (v) cur[k] = v; else delete cur[k] } body.propertiesExtra = cur } @@ -411,6 +418,7 @@ export function Kanban() { error={error} assigneeOptions={assigneeOptions} tagOptions={tagOptions} + onUploadAttachment={workspaceId ? (file) => api.uploadAsset(workspaceId, file).then((a) => a.url) : undefined} currentUser={getStoredUsername() ?? undefined} loadComments={workspaceId ? (cardId) => api.kanban.listComments(workspaceId, cardId) : undefined} addComment={workspaceId ? (cardId, body) => api.kanban.createComment(workspaceId, cardId, body) : undefined} diff --git a/services/jtype-web/frontend/src/pages/WebBoardView.tsx b/services/jtype-web/frontend/src/pages/WebBoardView.tsx index 93997e8..1e161b7 100644 --- a/services/jtype-web/frontend/src/pages/WebBoardView.tsx +++ b/services/jtype-web/frontend/src/pages/WebBoardView.tsx @@ -7,9 +7,11 @@ import { DEFAULT_DONE_COLUMN, bodyExcerpt, countTasks, + parseAttachments, parseLinks, parseTagList, pickCustomFields, + serializeAttachments, serializeLinks, slugify, type BoardViewCard, @@ -97,6 +99,7 @@ export function WebBoardView({ taskDone: tasks.done, taskTotal: tasks.total, excerpt: bodyExcerpt(fm.body), + attachments: fm.data.attachments ? parseAttachments(fm.data.attachments) : [], custom: pickCustomFields(fm.data, cfg.fields), blockedBy: fm.data.blocked_by ? parseLinks(fm.data.blocked_by) : [], blocks: fm.data.blocks ? parseLinks(fm.data.blocks) : [], @@ -226,6 +229,7 @@ export function WebBoardView({ if (patch.due !== undefined) next.due = patch.due ?? '' if (patch.icon !== undefined) next.icon = patch.icon ?? '' if (patch.tags !== undefined) next.tags = patch.tags.map((t) => t.label).join(', ') + if (patch.attachments !== undefined) next.attachments = serializeAttachments(patch.attachments) if (patch.custom !== undefined) for (const [k, v] of Object.entries(patch.custom)) next[k] = v ?? '' if (patch.blockedBy !== undefined) next.blocked_by = serializeLinks(patch.blockedBy) if (patch.blocks !== undefined) next.blocks = serializeLinks(patch.blocks) @@ -368,6 +372,7 @@ export function WebBoardView({ cards={cards} actions={actions} error={error} + onUploadAttachment={(file) => api.uploadAsset(workspaceId, file).then((a) => a.url)} fullscreen={fullscreen} onToggleFullscreen={onToggleFullscreen} /> diff --git a/shared/components/board/BoardPeek.tsx b/shared/components/board/BoardPeek.tsx index 73495a2..db9399d 100644 --- a/shared/components/board/BoardPeek.tsx +++ b/shared/components/board/BoardPeek.tsx @@ -1,9 +1,9 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon, ChatBubbleLeftIcon, ClockIcon } from "@heroicons/react/24/outline"; +import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon, PaperClipIcon, ArrowUpTrayIcon, ChatBubbleLeftIcon, ClockIcon } from "@heroicons/react/24/outline"; import { renderToContainer } from "../../lib/markdown"; -import { PRIORITIES, type BoardViewCard } from "../../lib/board"; +import { PRIORITIES, attachmentName, isSafeAttachmentUrl, type BoardViewCard } from "../../lib/board"; import { fieldCls, EmojiField, ListboxSelect, TagMultiSelect } from "./controls"; import type { BoardOption } from "./types"; import type { BoardTag, BoardFieldDef, BoardComment, BoardActivityEvent } from "../../lib/board"; @@ -22,6 +22,7 @@ export function BoardPeek({ onAddField, dependencyCards, loadNotes, + onUploadAttachment, loadComments, addComment, deleteComment, @@ -43,6 +44,7 @@ export function BoardPeek({ /** Sibling cards (excluding this one) offered as dependency targets. */ dependencyCards?: { slug: string; title: string }[]; loadNotes?: (id: string) => Promise; + onUploadAttachment?: (file: File) => Promise; loadComments?: (id: string) => Promise; addComment?: (id: string, body: string) => Promise; deleteComment?: (commentId: string) => Promise; @@ -57,6 +59,8 @@ export function BoardPeek({ const [draft, setDraft] = useState(card); const [notes, setNotes] = useState(card.notes ?? ""); const [mode, setMode] = useState<"write" | "preview">("write"); + const [newAttach, setNewAttach] = useState(""); + const [uploading, setUploading] = useState(false); const [comments, setComments] = useState([]); const [newComment, setNewComment] = useState(""); const [activity, setActivity] = useState([]); @@ -167,6 +171,22 @@ export function BoardPeek({ const tagLabels = draft.tags.map((t2) => t2.label); + const attachments = draft.attachments ?? []; + const setAttachments = (next: string[]) => setField({ attachments: next }, true); + const addAttachment = (url: string) => { + const u = url.trim(); + if (u && !attachments.includes(u)) setAttachments([...attachments, u]); + }; + const handleUpload = async (file: File | undefined) => { + if (!file || !onUploadAttachment) return; + setUploading(true); + try { + addAttachment(await onUploadAttachment(file)); + } finally { + setUploading(false); + } + }; + // Dependency editing maps slug<->title so the picker shows titles while the // card stores slugs. Unresolved slugs (renamed/cross-board) are preserved as-is. const slugToTitle = new Map((dependencyCards ?? []).map((c) => [c.slug, c.title])); @@ -354,6 +374,65 @@ export function BoardPeek({ )} +
+ + Attachments + + {attachments.length > 0 && ( +
+ {attachments.map((url) => ( +
+ + {isSafeAttachmentUrl(url) ? ( + + {attachmentName(url)} + + ) : ( + + {attachmentName(url)} ({t`unsafe`}) + + )} + +
+ ))} +
+ )} +
+
{ + e.preventDefault(); + addAttachment(newAttach); + setNewAttach(""); + }} + > + setNewAttach(e.target.value)} /> +
+ {onUploadAttachment && ( + + )} +
+
+
Notes diff --git a/shared/components/board/BoardSurface.tsx b/shared/components/board/BoardSurface.tsx index 8127ddc..c40373b 100644 --- a/shared/components/board/BoardSurface.tsx +++ b/shared/components/board/BoardSurface.tsx @@ -73,6 +73,7 @@ export function BoardSurface({ assigneeOptions, tagOptions, loadNotes, + onUploadAttachment, loadComments, addComment, deleteComment, @@ -898,6 +899,7 @@ export function BoardSurface({ }} dependencyCards={cards.filter((c) => c.id !== selected.id).map((c) => ({ slug: cardSlug(c), title: c.title }))} loadNotes={loadNotes} + onUploadAttachment={onUploadAttachment} loadComments={loadComments} addComment={addComment} deleteComment={deleteComment} diff --git a/shared/components/board/types.ts b/shared/components/board/types.ts index 4493de1..25c129e 100644 --- a/shared/components/board/types.ts +++ b/shared/components/board/types.ts @@ -40,6 +40,8 @@ export type BoardSurfaceProps = { tagOptions?: BoardTag[]; /** Lazily load a card's notes/body when opening the peek (desktop). */ loadNotes?: (cardId: string) => Promise; + /** Upload a file as a card attachment, returning its URL/path. Omit to allow only URL/path entry. */ + onUploadAttachment?: (file: File) => Promise; /** Card comments (DB board). Supply all three + currentUser to enable the section. */ loadComments?: (cardId: string) => Promise; addComment?: (cardId: string, body: string) => Promise; diff --git a/shared/i18n/locales/en/messages.mjs b/shared/i18n/locales/en/messages.mjs index 33ca39f..c1e785e 100644 --- a/shared/i18n/locales/en/messages.mjs +++ b/shared/i18n/locales/en/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gLDJuJ\":[\"Untitled card\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"ucJg3u\":[\"Swimlane: Status\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"Notes\"],\"1YABGm\":[\"Link (Ctrl+K)\"],\"1hKEom\":[\"Priority\"],\"1lWHP7\":[\"unsafe\"],\"2wxgft\":[\"Rename\"],\"3qkggm\":[\"Fullscreen\"],\"4gdyen\":[\"Local (yours)\"],\"4hJhzz\":[\"Table\"],\"54sFiP\":[\"flowchart TD\\n A[Start] --> B[End]\"],\"5Q_DQ6\":[\"Inline Code\"],\"7VpPHA\":[\"Confirm\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid diagram\"],\"8hSn0h\":[\"Result (editable)\"],\"8lE269\":[\"Sort: Manual\"],\"9gxam6\":[\"Could not render this Draw.io diagram.\"],\"AC9Gkf\":[\"Expand column\"],\"AS5WO9\":[\"Could not render this PDF.\"],\"AVreQ5\":[\"Drag to resize\"],\"AgvHni\":[\"Add column\"],\"AxAubu\":[\"Group: Assignee\"],\"BfMZ7w\":[\"Accept cloud\"],\"BnmEvM\":[\"Save as template\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"Code\"],\"EbMPZJ\":[\"Unassigned\"],\"G4qrLy\":[\"Unset done column\"],\"GKu3m4\":[\"No labels\"],\"Gpfctt\":[\"Due\"],\"H_SQFv\":[\"No color\"],\"I6SWEy\":[\"Split\"],\"ICip_B\":[\"Cloud (remote)\"],\"Ik60OC\":[\"Open in editor\"],\"Iw6WJa\":[\"Set WIP limit\"],\"JTYvAw\":[\"Search cards\"],\"K_F6pa\":[\"Saving…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"Bold\"],\"KvW1VO\":[\"Draw.io diagram\"],\"LQn6-8\":[\"Accept local\"],\"MHrjPM\":[\"Title\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"ONWvwQ\":[\"Upload\"],\"OYHzN1\":[\"Tags\"],\"OepdfE\":[\"Group: Status\"],\"Pvpx7b\":[\"Paste a URL or path\"],\"Q2mGA7\":[\"Clear filter\"],\"QD8opX\":[\"Board\"],\"QlsPZy\":[\"Write Mermaid syntax to see the diagram.\"],\"S5Qbb1\":[\"comma, separated\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"Blank card\"],\"VNa_N2\":[\"This file type can not be previewed yet.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"Sort: Priority\"],\"X03-eC\":[\"Please enter a value.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"Diagram error\"],\"Zot9XS\":[\"No cards\"],\"_5CsXX\":[\"Done column\"],\"_EsjyQ\":[\"Use this\"],\"a6uhHr\":[\"Bold (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"Add details...\"],\"agOeRN\":[\"Could not render this API specification.\"],\"b4hVKD\":[\"Color columns\"],\"cfaWH-\":[\"Add labels\"],\"cnGeoo\":[\"Delete\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP limit \",[\"0\"]],\"dEgA5A\":[\"Cancel\"],\"euc6Ns\":[\"Duplicate\"],\"fYcKtB\":[\"Sort: Due\"],\"gANddk\":[\"Uploading…\"],\"gLDJuJ\":[\"Untitled card\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF document\"],\"i4_LY_\":[\"Write\"],\"iTylMl\":[\"Templates\"],\"iYVqZq\":[\"Column name\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"Color\"],\"kZlRKE\":[\"Mermaid source\"],\"kryGs-\":[\"Card\"],\"lCF0wC\":[\"Refresh\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"Save merged result\"],\"nabda1\":[\"Delete card\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"Filter\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"Assignee\"],\"p9yTeb\":[\"Sort: Title\"],\"pKztsX\":[\"Open in full editor\"],\"pnrmSP\":[\"New card\"],\"pwN6Ae\":[\"Collapse column\"],\"pzutoc\":[\"Italic\"],\"rdUucN\":[\"Preview\"],\"sCzmvQ\":[\"cards\"],\"sQpDn6\":[\"Exit fullscreen\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" Conflict\",[\"1\"],\" to Resolve\"],\"t_YqKh\":[\"Remove\"],\"u2IprG\":[\"Card title (Enter to add, Esc to cancel)\"],\"uAQUqI\":[\"Status\"],\"ucJg3u\":[\"Swimlane: Status\"],\"w7E-FA\":[\"Unsafe link blocked: \",[\"url\"]],\"w_Sphq\":[\"Attachments\"],\"wf6Djn\":[\"Italic (Ctrl+I)\"],\"wtw-au\":[\"Set as done column\"],\"wwu18a\":[\"Icon\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"Copy link\"],\"y9cj46\":[\"Group: Priority\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← Back to list\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"zOc0vf\":[\"No icon\"],\"zga9sT\":[\"OK\"]}"); \ No newline at end of file diff --git a/shared/i18n/locales/en/messages.po b/shared/i18n/locales/en/messages.po index 1c18c4f..e623c49 100644 --- a/shared/i18n/locales/en/messages.po +++ b/shared/i18n/locales/en/messages.po @@ -70,6 +70,10 @@ msgstr "Archived" msgid "Assignee" msgstr "Assignee" +#: shared/components/board/BoardPeek.tsx +msgid "Attachments" +msgstr "Attachments" + #: shared/components/board/BoardSurface.tsx msgid "Blank card" msgstr "Blank card" @@ -332,6 +336,10 @@ msgstr "Open in editor" msgid "Open in full editor" msgstr "Open in full editor" +#: shared/components/board/BoardPeek.tsx +msgid "Paste a URL or path" +msgstr "Paste a URL or path" + #: shared/components/viewers/PdfView.tsx msgid "PDF document" msgstr "PDF document" @@ -359,6 +367,10 @@ msgstr "Refresh" msgid "Relates" msgstr "Relates" +#: shared/components/board/BoardPeek.tsx +msgid "Remove" +msgstr "Remove" + #: shared/components/board/BoardSurface.tsx msgid "Rename" msgstr "Rename" @@ -467,6 +479,14 @@ msgstr "Title" msgid "Unassigned" msgstr "Unassigned" +#: shared/components/board/BoardPeek.tsx +msgid "unsafe" +msgstr "unsafe" + +#: shared/components/board/BoardPeek.tsx +msgid "Unsafe link blocked: {url}" +msgstr "Unsafe link blocked: {url}" + #: shared/components/board/BoardSurface.tsx msgid "Unset done column" msgstr "Unset done column" @@ -479,6 +499,14 @@ msgstr "Untitled card" msgid "Updated" msgstr "Updated" +#: shared/components/board/BoardPeek.tsx +msgid "Upload" +msgstr "Upload" + +#: shared/components/board/BoardPeek.tsx +msgid "Uploading…" +msgstr "Uploading…" + #: shared/components/ConflictResolver.tsx msgid "Use this" msgstr "Use this" diff --git a/shared/i18n/locales/ja/messages.mjs b/shared/i18n/locales/ja/messages.mjs index b694198..eaa32ed 100644 --- a/shared/i18n/locales/ja/messages.mjs +++ b/shared/i18n/locales/ja/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"ノート\"],\"1YABGm\":[\"リンク (Ctrl+K)\"],\"1hKEom\":[\"優先度\"],\"2wxgft\":[\"名前を変更\"],\"3qkggm\":[\"全画面表示\"],\"4gdyen\":[\"ローカル(自分の)\"],\"4hJhzz\":[\"表\"],\"54sFiP\":[\"flowchart TD\\n A[開始] --> B[終了]\"],\"5Q_DQ6\":[\"インラインコード\"],\"7VpPHA\":[\"確認\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid 図\"],\"8hSn0h\":[\"結果(編集可能)\"],\"8lE269\":[\"並べ替え:手動\"],\"9gxam6\":[\"この Draw.io 図をレンダリングできませんでした。\"],\"AC9Gkf\":[\"列を展開\"],\"AS5WO9\":[\"この PDF をレンダリングできませんでした。\"],\"AVreQ5\":[\"ドラッグしてサイズ変更\"],\"AgvHni\":[\"列を追加\"],\"AxAubu\":[\"グループ:担当者\"],\"BfMZ7w\":[\"クラウドを採用\"],\"BnmEvM\":[\"テンプレートとして保存\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"コード\"],\"EbMPZJ\":[\"未割り当て\"],\"G4qrLy\":[\"完了列を解除\"],\"GKu3m4\":[\"ラベルなし\"],\"Gpfctt\":[\"期限\"],\"H_SQFv\":[\"色なし\"],\"I6SWEy\":[\"分割\"],\"ICip_B\":[\"クラウド(リモート)\"],\"Ik60OC\":[\"エディターで開く\"],\"Iw6WJa\":[\"WIP 制限を設定\"],\"JTYvAw\":[\"カードを検索\"],\"K_F6pa\":[\"保存中…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"太字\"],\"KvW1VO\":[\"Draw.io 図\"],\"LQn6-8\":[\"ローカルを採用\"],\"MHrjPM\":[\"タイトル\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"タグ\"],\"OepdfE\":[\"グループ:ステータス\"],\"Q2mGA7\":[\"フィルターをクリア\"],\"QD8opX\":[\"ボード\"],\"QlsPZy\":[\"Mermaid 構文を書くと図が表示されます。\"],\"S5Qbb1\":[\"カンマ区切り\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"空のカード\"],\"VNa_N2\":[\"このファイル形式はまだプレビューできません。\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"並べ替え:優先度\"],\"X03-eC\":[\"値を入力してください。\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"図のエラー\"],\"Zot9XS\":[\"カードなし\"],\"_5CsXX\":[\"完了列\"],\"_EsjyQ\":[\"これを使用\"],\"a6uhHr\":[\"太字 (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"詳細を追加...\"],\"agOeRN\":[\"この API 仕様をレンダリングできませんでした。\"],\"b4hVKD\":[\"色付き列\"],\"cfaWH-\":[\"ラベルを追加\"],\"cnGeoo\":[\"削除\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP 制限 \",[\"0\"]],\"dEgA5A\":[\"キャンセル\"],\"euc6Ns\":[\"複製\"],\"fYcKtB\":[\"並べ替え:期限\"],\"gLDJuJ\":[\"無題のカード\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF ドキュメント\"],\"i4_LY_\":[\"記述\"],\"iTylMl\":[\"テンプレート\"],\"iYVqZq\":[\"列名\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"カラー\"],\"kZlRKE\":[\"Mermaid ソース\"],\"kryGs-\":[\"カード\"],\"lCF0wC\":[\"更新\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"マージ結果を保存\"],\"nabda1\":[\"カードを削除\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"フィルター\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"担当者\"],\"p9yTeb\":[\"並べ替え:タイトル\"],\"pKztsX\":[\"フルエディターで開く\"],\"pnrmSP\":[\"新規カード\"],\"pwN6Ae\":[\"列を折りたたむ\"],\"pzutoc\":[\"イタリック\"],\"rdUucN\":[\"プレビュー\"],\"sCzmvQ\":[\"枚のカード\"],\"sQpDn6\":[\"全画面表示を終了\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" 件の競合\",[\"1\"],\"を解決中\"],\"u2IprG\":[\"カードのタイトル(Enter で追加、Esc でキャンセル)\"],\"uAQUqI\":[\"ステータス\"],\"ucJg3u\":[\"Swimlane: Status\"],\"wf6Djn\":[\"イタリック (Ctrl+I)\"],\"wtw-au\":[\"完了列に設定\"],\"wwu18a\":[\"アイコン\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"リンクをコピー\"],\"y9cj46\":[\"グループ:優先度\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← リストに戻る\"],\"yz7wBu\":[\"閉じる\"],\"yzF66j\":[\"リンク\"],\"zOc0vf\":[\"アイコンなし\"],\"zga9sT\":[\"OK\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"ノート\"],\"1YABGm\":[\"リンク (Ctrl+K)\"],\"1hKEom\":[\"優先度\"],\"1lWHP7\":[\"unsafe\"],\"2wxgft\":[\"名前を変更\"],\"3qkggm\":[\"全画面表示\"],\"4gdyen\":[\"ローカル(自分の)\"],\"4hJhzz\":[\"表\"],\"54sFiP\":[\"flowchart TD\\n A[開始] --> B[終了]\"],\"5Q_DQ6\":[\"インラインコード\"],\"7VpPHA\":[\"確認\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid 図\"],\"8hSn0h\":[\"結果(編集可能)\"],\"8lE269\":[\"並べ替え:手動\"],\"9gxam6\":[\"この Draw.io 図をレンダリングできませんでした。\"],\"AC9Gkf\":[\"列を展開\"],\"AS5WO9\":[\"この PDF をレンダリングできませんでした。\"],\"AVreQ5\":[\"ドラッグしてサイズ変更\"],\"AgvHni\":[\"列を追加\"],\"AxAubu\":[\"グループ:担当者\"],\"BfMZ7w\":[\"クラウドを採用\"],\"BnmEvM\":[\"テンプレートとして保存\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"コード\"],\"EbMPZJ\":[\"未割り当て\"],\"G4qrLy\":[\"完了列を解除\"],\"GKu3m4\":[\"ラベルなし\"],\"Gpfctt\":[\"期限\"],\"H_SQFv\":[\"色なし\"],\"I6SWEy\":[\"分割\"],\"ICip_B\":[\"クラウド(リモート)\"],\"Ik60OC\":[\"エディターで開く\"],\"Iw6WJa\":[\"WIP 制限を設定\"],\"JTYvAw\":[\"カードを検索\"],\"K_F6pa\":[\"保存中…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"太字\"],\"KvW1VO\":[\"Draw.io 図\"],\"LQn6-8\":[\"ローカルを採用\"],\"MHrjPM\":[\"タイトル\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"ONWvwQ\":[\"Upload\"],\"OYHzN1\":[\"タグ\"],\"OepdfE\":[\"グループ:ステータス\"],\"Pvpx7b\":[\"Paste a URL or path\"],\"Q2mGA7\":[\"フィルターをクリア\"],\"QD8opX\":[\"ボード\"],\"QlsPZy\":[\"Mermaid 構文を書くと図が表示されます。\"],\"S5Qbb1\":[\"カンマ区切り\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"空のカード\"],\"VNa_N2\":[\"このファイル形式はまだプレビューできません。\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"並べ替え:優先度\"],\"X03-eC\":[\"値を入力してください。\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"図のエラー\"],\"Zot9XS\":[\"カードなし\"],\"_5CsXX\":[\"完了列\"],\"_EsjyQ\":[\"これを使用\"],\"a6uhHr\":[\"太字 (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"詳細を追加...\"],\"agOeRN\":[\"この API 仕様をレンダリングできませんでした。\"],\"b4hVKD\":[\"色付き列\"],\"cfaWH-\":[\"ラベルを追加\"],\"cnGeoo\":[\"削除\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP 制限 \",[\"0\"]],\"dEgA5A\":[\"キャンセル\"],\"euc6Ns\":[\"複製\"],\"fYcKtB\":[\"並べ替え:期限\"],\"gANddk\":[\"Uploading…\"],\"gLDJuJ\":[\"無題のカード\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF ドキュメント\"],\"i4_LY_\":[\"記述\"],\"iTylMl\":[\"テンプレート\"],\"iYVqZq\":[\"列名\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"カラー\"],\"kZlRKE\":[\"Mermaid ソース\"],\"kryGs-\":[\"カード\"],\"lCF0wC\":[\"更新\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"マージ結果を保存\"],\"nabda1\":[\"カードを削除\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"フィルター\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"担当者\"],\"p9yTeb\":[\"並べ替え:タイトル\"],\"pKztsX\":[\"フルエディターで開く\"],\"pnrmSP\":[\"新規カード\"],\"pwN6Ae\":[\"列を折りたたむ\"],\"pzutoc\":[\"イタリック\"],\"rdUucN\":[\"プレビュー\"],\"sCzmvQ\":[\"枚のカード\"],\"sQpDn6\":[\"全画面表示を終了\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" 件の競合\",[\"1\"],\"を解決中\"],\"t_YqKh\":[\"Remove\"],\"u2IprG\":[\"カードのタイトル(Enter で追加、Esc でキャンセル)\"],\"uAQUqI\":[\"ステータス\"],\"ucJg3u\":[\"Swimlane: Status\"],\"w7E-FA\":[\"Unsafe link blocked: \",[\"url\"]],\"w_Sphq\":[\"Attachments\"],\"wf6Djn\":[\"イタリック (Ctrl+I)\"],\"wtw-au\":[\"完了列に設定\"],\"wwu18a\":[\"アイコン\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"リンクをコピー\"],\"y9cj46\":[\"グループ:優先度\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← リストに戻る\"],\"yz7wBu\":[\"閉じる\"],\"yzF66j\":[\"リンク\"],\"zOc0vf\":[\"アイコンなし\"],\"zga9sT\":[\"OK\"]}"); \ No newline at end of file diff --git a/shared/i18n/locales/ja/messages.po b/shared/i18n/locales/ja/messages.po index a1f8e10..cdcc380 100644 --- a/shared/i18n/locales/ja/messages.po +++ b/shared/i18n/locales/ja/messages.po @@ -70,6 +70,10 @@ msgstr "" msgid "Assignee" msgstr "担当者" +#: shared/components/board/BoardPeek.tsx +msgid "Attachments" +msgstr "" + #: shared/components/board/BoardSurface.tsx msgid "Blank card" msgstr "空のカード" @@ -332,6 +336,10 @@ msgstr "エディターで開く" msgid "Open in full editor" msgstr "フルエディターで開く" +#: shared/components/board/BoardPeek.tsx +msgid "Paste a URL or path" +msgstr "" + #: shared/components/viewers/PdfView.tsx msgid "PDF document" msgstr "PDF ドキュメント" @@ -359,6 +367,10 @@ msgstr "更新" msgid "Relates" msgstr "" +#: shared/components/board/BoardPeek.tsx +msgid "Remove" +msgstr "" + #: shared/components/board/BoardSurface.tsx msgid "Rename" msgstr "名前を変更" @@ -467,6 +479,14 @@ msgstr "タイトル" msgid "Unassigned" msgstr "未割り当て" +#: shared/components/board/BoardPeek.tsx +msgid "unsafe" +msgstr "" + +#: shared/components/board/BoardPeek.tsx +msgid "Unsafe link blocked: {url}" +msgstr "" + #: shared/components/board/BoardSurface.tsx msgid "Unset done column" msgstr "完了列を解除" @@ -479,6 +499,14 @@ msgstr "無題のカード" msgid "Updated" msgstr "" +#: shared/components/board/BoardPeek.tsx +msgid "Upload" +msgstr "" + +#: shared/components/board/BoardPeek.tsx +msgid "Uploading…" +msgstr "" + #: shared/components/ConflictResolver.tsx msgid "Use this" msgstr "これを使用" diff --git a/shared/i18n/locales/ko/messages.mjs b/shared/i18n/locales/ko/messages.mjs index 059685b..d58e811 100644 --- a/shared/i18n/locales/ko/messages.mjs +++ b/shared/i18n/locales/ko/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"노트\"],\"1YABGm\":[\"링크 (Ctrl+K)\"],\"1hKEom\":[\"우선순위\"],\"2wxgft\":[\"이름 변경\"],\"3qkggm\":[\"전체 화면\"],\"4gdyen\":[\"로컈 (내 것)\"],\"4hJhzz\":[\"테이블\"],\"54sFiP\":[\"flowchart TD\\n A[시작] --> B[끝]\"],\"5Q_DQ6\":[\"인라인 코드\"],\"7VpPHA\":[\"확인\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid 다이어그램\"],\"8hSn0h\":[\"결과 (편집 가능)\"],\"8lE269\":[\"정렬: 수동\"],\"9gxam6\":[\"이 Draw.io 다이어그램을 렌더링할 수 없습니다.\"],\"AC9Gkf\":[\"열 펼치기\"],\"AS5WO9\":[\"이 PDF를 렌더링할 수 없습니다.\"],\"AVreQ5\":[\"드래그하여 크기 조정\"],\"AgvHni\":[\"열 추가\"],\"AxAubu\":[\"그룹: 담당자\"],\"BfMZ7w\":[\"클라우드 수낙\"],\"BnmEvM\":[\"템플릿으로 저장\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"코드\"],\"EbMPZJ\":[\"미할당\"],\"G4qrLy\":[\"완료 열 해제\"],\"GKu3m4\":[\"라벨 없음\"],\"Gpfctt\":[\"마감\"],\"H_SQFv\":[\"색상 없음\"],\"I6SWEy\":[\"스플릿\"],\"ICip_B\":[\"클라우드 (원격)\"],\"Ik60OC\":[\"에디터에서 열기\"],\"Iw6WJa\":[\"WIP 한도 설정\"],\"JTYvAw\":[\"카드 검색\"],\"K_F6pa\":[\"저장 중…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"굵게\"],\"KvW1VO\":[\"Draw.io 다이어그램\"],\"LQn6-8\":[\"로컈 수낙\"],\"MHrjPM\":[\"제목\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"OYHzN1\":[\"태그\"],\"OepdfE\":[\"그룹: 상태\"],\"Q2mGA7\":[\"필터 지우기\"],\"QD8opX\":[\"보드\"],\"QlsPZy\":[\"Mermaid 구문을 작성하면 다이어그램이 표시됩니다.\"],\"S5Qbb1\":[\"쉼표로 구분\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"빈 카드\"],\"VNa_N2\":[\"이 파일 형식은 아직 미리볼 수 없습니다.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"정렬: 우선순위\"],\"X03-eC\":[\"값을 입력해 주세요.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"다이어그램 오류\"],\"Zot9XS\":[\"카드 없음\"],\"_5CsXX\":[\"완료 열\"],\"_EsjyQ\":[\"이것 사용\"],\"a6uhHr\":[\"굵게 (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"세부정보 추가...\"],\"agOeRN\":[\"이 API 명세를 렌더링할 수 없습니다.\"],\"b4hVKD\":[\"색상 열\"],\"cfaWH-\":[\"라벨 추가\"],\"cnGeoo\":[\"삭제\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP 한도 \",[\"0\"]],\"dEgA5A\":[\"취소\"],\"euc6Ns\":[\"복제\"],\"fYcKtB\":[\"정렬: 마감\"],\"gLDJuJ\":[\"제목 없는 카드\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF 문서\"],\"i4_LY_\":[\"작성\"],\"iTylMl\":[\"템플릿\"],\"iYVqZq\":[\"열 이름\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"색상\"],\"kZlRKE\":[\"Mermaid 소스\"],\"kryGs-\":[\"카드\"],\"lCF0wC\":[\"새로고침\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"병합 결과 저장\"],\"nabda1\":[\"카드 삭제\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"필터\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"담당자\"],\"p9yTeb\":[\"정렬: 제목\"],\"pKztsX\":[\"전체 에디터에서 열기\"],\"pnrmSP\":[\"새 카드\"],\"pwN6Ae\":[\"열 접기\"],\"pzutoc\":[\"기울임꼴\"],\"rdUucN\":[\"미리보기\"],\"sCzmvQ\":[\"개 카드\"],\"sQpDn6\":[\"전체 화면 종료\"],\"tK2x9T\":[\"⚠ 해결할 충돌 \",[\"0\"],\"건\",[\"1\"]],\"u2IprG\":[\"카드 제목 (Enter로 추가, Esc로 취소)\"],\"uAQUqI\":[\"상태\"],\"ucJg3u\":[\"Swimlane: Status\"],\"wf6Djn\":[\"기울임꼴 (Ctrl+I)\"],\"wtw-au\":[\"완료 열로 설정\"],\"wwu18a\":[\"아이콘\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"링크 복사\"],\"y9cj46\":[\"그룹: 우선순위\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← 목록으로\"],\"yz7wBu\":[\"닫기\"],\"yzF66j\":[\"링크\"],\"zOc0vf\":[\"아이콘 없음\"],\"zga9sT\":[\"확인\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"Blocked by\"],\"-b7T3G\":[\"Updated\"],\"1DBGsz\":[\"노트\"],\"1YABGm\":[\"링크 (Ctrl+K)\"],\"1hKEom\":[\"우선순위\"],\"1lWHP7\":[\"unsafe\"],\"2wxgft\":[\"이름 변경\"],\"3qkggm\":[\"전체 화면\"],\"4gdyen\":[\"로컈 (내 것)\"],\"4hJhzz\":[\"테이블\"],\"54sFiP\":[\"flowchart TD\\n A[시작] --> B[끝]\"],\"5Q_DQ6\":[\"인라인 코드\"],\"7VpPHA\":[\"확인\"],\"7s3WlU\":[\"Blocks\"],\"8PifYj\":[\"Mermaid 다이어그램\"],\"8hSn0h\":[\"결과 (편집 가능)\"],\"8lE269\":[\"정렬: 수동\"],\"9gxam6\":[\"이 Draw.io 다이어그램을 렌더링할 수 없습니다.\"],\"AC9Gkf\":[\"열 펼치기\"],\"AS5WO9\":[\"이 PDF를 렌더링할 수 없습니다.\"],\"AVreQ5\":[\"드래그하여 크기 조정\"],\"AgvHni\":[\"열 추가\"],\"AxAubu\":[\"그룹: 담당자\"],\"BfMZ7w\":[\"클라우드 수낙\"],\"BnmEvM\":[\"템플릿으로 저장\"],\"C6-ZRl\":[\"Someone\"],\"EWPtMO\":[\"코드\"],\"EbMPZJ\":[\"미할당\"],\"G4qrLy\":[\"완료 열 해제\"],\"GKu3m4\":[\"라벨 없음\"],\"Gpfctt\":[\"마감\"],\"H_SQFv\":[\"색상 없음\"],\"I6SWEy\":[\"스플릿\"],\"ICip_B\":[\"클라우드 (원격)\"],\"Ik60OC\":[\"에디터에서 열기\"],\"Iw6WJa\":[\"WIP 한도 설정\"],\"JTYvAw\":[\"카드 검색\"],\"K_F6pa\":[\"저장 중…\"],\"KjXDqG\":[\"Swimlane: None\"],\"KmydK6\":[\"굵게\"],\"KvW1VO\":[\"Draw.io 다이어그램\"],\"LQn6-8\":[\"로컈 수낙\"],\"MHrjPM\":[\"제목\"],\"Mm72la\":[\"No comments yet\"],\"NBdIgR\":[\"Comment\"],\"ONWvwQ\":[\"Upload\"],\"OYHzN1\":[\"태그\"],\"OepdfE\":[\"그룹: 상태\"],\"Pvpx7b\":[\"Paste a URL or path\"],\"Q2mGA7\":[\"필터 지우기\"],\"QD8opX\":[\"보드\"],\"QlsPZy\":[\"Mermaid 구문을 작성하면 다이어그램이 표시됩니다.\"],\"S5Qbb1\":[\"쉼표로 구분\"],\"TdfEV7\":[\"Archived\"],\"UQOvxZ\":[\"빈 카드\"],\"VNa_N2\":[\"이 파일 형식은 아직 미리볼 수 없습니다.\"],\"VbyRUy\":[\"Comments\"],\"WSP6v1\":[\"정렬: 우선순위\"],\"X03-eC\":[\"값을 입력해 주세요.\"],\"XJOV1Y\":[\"Activity\"],\"Ya7bZl\":[\"다이어그램 오류\"],\"Zot9XS\":[\"카드 없음\"],\"_5CsXX\":[\"완료 열\"],\"_EsjyQ\":[\"이것 사용\"],\"a6uhHr\":[\"굵게 (Ctrl+B)\"],\"aDvLhk\":[\"Add a comment…\"],\"abUZlY\":[\"세부정보 추가...\"],\"agOeRN\":[\"이 API 명세를 렌더링할 수 없습니다.\"],\"b4hVKD\":[\"색상 열\"],\"cfaWH-\":[\"라벨 추가\"],\"cnGeoo\":[\"삭제\"],\"d-F6q9\":[\"Created\"],\"d5z6xQ\":[\"WIP 한도 \",[\"0\"]],\"dEgA5A\":[\"취소\"],\"euc6Ns\":[\"복제\"],\"fYcKtB\":[\"정렬: 마감\"],\"gANddk\":[\"Uploading…\"],\"gLDJuJ\":[\"제목 없는 카드\"],\"hh4sEG\":[\"Relates\"],\"hnK1gR\":[\"PDF 문서\"],\"i4_LY_\":[\"작성\"],\"iTylMl\":[\"템플릿\"],\"iYVqZq\":[\"열 이름\"],\"jUbC3Z\":[\"Swimlane: Priority\"],\"jZlrte\":[\"색상\"],\"kZlRKE\":[\"Mermaid 소스\"],\"kryGs-\":[\"카드\"],\"lCF0wC\":[\"새로고침\"],\"lHxVTh\":[\"Swimlane: Assignee\"],\"ltF1xa\":[\"병합 결과 저장\"],\"nabda1\":[\"카드 삭제\"],\"njJFtc\":[\"Delete comment\"],\"o7J4JM\":[\"필터\"],\"o8va6N\":[\"Restored\"],\"ojKCLU\":[\"담당자\"],\"p9yTeb\":[\"정렬: 제목\"],\"pKztsX\":[\"전체 에디터에서 열기\"],\"pnrmSP\":[\"새 카드\"],\"pwN6Ae\":[\"열 접기\"],\"pzutoc\":[\"기울임꼴\"],\"rdUucN\":[\"미리보기\"],\"sCzmvQ\":[\"개 카드\"],\"sQpDn6\":[\"전체 화면 종료\"],\"tK2x9T\":[\"⚠ 해결할 충돌 \",[\"0\"],\"건\",[\"1\"]],\"t_YqKh\":[\"Remove\"],\"u2IprG\":[\"카드 제목 (Enter로 추가, Esc로 취소)\"],\"uAQUqI\":[\"상태\"],\"ucJg3u\":[\"Swimlane: Status\"],\"w7E-FA\":[\"Unsafe link blocked: \",[\"url\"]],\"w_Sphq\":[\"Attachments\"],\"wf6Djn\":[\"기울임꼴 (Ctrl+I)\"],\"wtw-au\":[\"완료 열로 설정\"],\"wwu18a\":[\"아이콘\"],\"x52RAh\":[\"Blocked by \",[\"blockedCount\"],\" unfinished card(s)\"],\"y1eoq1\":[\"링크 복사\"],\"y9cj46\":[\"그룹: 우선순위\"],\"yEbJGs\":[\"+ Add field\"],\"ybGQtY\":[\"← 목록으로\"],\"yz7wBu\":[\"닫기\"],\"yzF66j\":[\"링크\"],\"zOc0vf\":[\"아이콘 없음\"],\"zga9sT\":[\"확인\"]}"); \ No newline at end of file diff --git a/shared/i18n/locales/ko/messages.po b/shared/i18n/locales/ko/messages.po index 70b0b22..e6c7fdc 100644 --- a/shared/i18n/locales/ko/messages.po +++ b/shared/i18n/locales/ko/messages.po @@ -70,6 +70,10 @@ msgstr "" msgid "Assignee" msgstr "담당자" +#: shared/components/board/BoardPeek.tsx +msgid "Attachments" +msgstr "" + #: shared/components/board/BoardSurface.tsx msgid "Blank card" msgstr "빈 카드" @@ -332,6 +336,10 @@ msgstr "에디터에서 열기" msgid "Open in full editor" msgstr "전체 에디터에서 열기" +#: shared/components/board/BoardPeek.tsx +msgid "Paste a URL or path" +msgstr "" + #: shared/components/viewers/PdfView.tsx msgid "PDF document" msgstr "PDF 문서" @@ -359,6 +367,10 @@ msgstr "새로고침" msgid "Relates" msgstr "" +#: shared/components/board/BoardPeek.tsx +msgid "Remove" +msgstr "" + #: shared/components/board/BoardSurface.tsx msgid "Rename" msgstr "이름 변경" @@ -467,6 +479,14 @@ msgstr "제목" msgid "Unassigned" msgstr "미할당" +#: shared/components/board/BoardPeek.tsx +msgid "unsafe" +msgstr "" + +#: shared/components/board/BoardPeek.tsx +msgid "Unsafe link blocked: {url}" +msgstr "" + #: shared/components/board/BoardSurface.tsx msgid "Unset done column" msgstr "완료 열 해제" @@ -479,6 +499,14 @@ msgstr "제목 없는 카드" msgid "Updated" msgstr "" +#: shared/components/board/BoardPeek.tsx +msgid "Upload" +msgstr "" + +#: shared/components/board/BoardPeek.tsx +msgid "Uploading…" +msgstr "" + #: shared/components/ConflictResolver.tsx msgid "Use this" msgstr "이것 사용" diff --git a/shared/i18n/locales/zh/messages.mjs b/shared/i18n/locales/zh/messages.mjs index 7405069..be0057c 100644 --- a/shared/i18n/locales/zh/messages.mjs +++ b/shared/i18n/locales/zh/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"被阻塞于\"],\"-b7T3G\":[\"更新\"],\"1DBGsz\":[\"备注\"],\"1YABGm\":[\"链接 (Ctrl+K)\"],\"1hKEom\":[\"优先级\"],\"2wxgft\":[\"重命名\"],\"3qkggm\":[\"全屏\"],\"4gdyen\":[\"本地(我的)\"],\"4hJhzz\":[\"表格\"],\"54sFiP\":[\"flowchart TD\\n A[开始] --> B[结束]\"],\"5Q_DQ6\":[\"行内代码\"],\"7VpPHA\":[\"确认\"],\"7s3WlU\":[\"阻塞\"],\"8PifYj\":[\"Mermaid 图表\"],\"8hSn0h\":[\"结果(可编辑)\"],\"8lE269\":[\"排序:手动\"],\"9gxam6\":[\"无法渲染此 Draw.io 图表。\"],\"AC9Gkf\":[\"展开列\"],\"AS5WO9\":[\"无法渲染此 PDF。\"],\"AVreQ5\":[\"拖动调整宽度\"],\"AgvHni\":[\"添加列\"],\"AxAubu\":[\"分组:负责人\"],\"BfMZ7w\":[\"接受云端\"],\"BnmEvM\":[\"存为模板\"],\"C6-ZRl\":[\"某人\"],\"EWPtMO\":[\"代码\"],\"EbMPZJ\":[\"未分配\"],\"G4qrLy\":[\"取消完成列\"],\"GKu3m4\":[\"暂无标签\"],\"Gpfctt\":[\"截止日期\"],\"H_SQFv\":[\"无颜色\"],\"I6SWEy\":[\"分栏\"],\"ICip_B\":[\"云端(远程)\"],\"Ik60OC\":[\"在编辑器中打开\"],\"Iw6WJa\":[\"设置 WIP 限制\"],\"JTYvAw\":[\"搜索卡片\"],\"K_F6pa\":[\"保存中…\"],\"KjXDqG\":[\"泳道:无\"],\"KmydK6\":[\"粗体\"],\"KvW1VO\":[\"Draw.io 图表\"],\"LQn6-8\":[\"接受本地\"],\"MHrjPM\":[\"标题\"],\"Mm72la\":[\"暂无评论\"],\"NBdIgR\":[\"评论\"],\"OYHzN1\":[\"标签\"],\"OepdfE\":[\"分组:状态\"],\"Q2mGA7\":[\"清除筛选\"],\"QD8opX\":[\"看板\"],\"QlsPZy\":[\"输入 Mermaid 语法以查看图表。\"],\"S5Qbb1\":[\"用逗号分隔\"],\"TdfEV7\":[\"归档\"],\"UQOvxZ\":[\"空白卡片\"],\"VNa_N2\":[\"暂不支持预览此文件类型。\"],\"VbyRUy\":[\"评论\"],\"WSP6v1\":[\"排序:优先级\"],\"X03-eC\":[\"请输入内容。\"],\"XJOV1Y\":[\"活动\"],\"Ya7bZl\":[\"图表错误\"],\"Zot9XS\":[\"暂无卡片\"],\"_5CsXX\":[\"完成列\"],\"_EsjyQ\":[\"使用此版本\"],\"a6uhHr\":[\"粗体 (Ctrl+B)\"],\"aDvLhk\":[\"添加评论…\"],\"abUZlY\":[\"添加详情...\"],\"agOeRN\":[\"无法渲染此 API 规范。\"],\"b4hVKD\":[\"彩色列\"],\"cfaWH-\":[\"添加标签\"],\"cnGeoo\":[\"删除\"],\"d-F6q9\":[\"创建\"],\"d5z6xQ\":[\"WIP 限制 \",[\"0\"]],\"dEgA5A\":[\"取消\"],\"euc6Ns\":[\"复制卡片\"],\"fYcKtB\":[\"排序:截止\"],\"gLDJuJ\":[\"未命名卡片\"],\"hh4sEG\":[\"相关\"],\"hnK1gR\":[\"PDF 文档\"],\"i4_LY_\":[\"写作\"],\"iTylMl\":[\"模板\"],\"iYVqZq\":[\"列名称\"],\"jUbC3Z\":[\"泳道:优先级\"],\"jZlrte\":[\"颜色\"],\"kZlRKE\":[\"Mermaid 源码\"],\"kryGs-\":[\"卡片\"],\"lCF0wC\":[\"刷新\"],\"lHxVTh\":[\"泳道:负责人\"],\"ltF1xa\":[\"保存合并结果\"],\"nabda1\":[\"删除卡片\"],\"njJFtc\":[\"删除评论\"],\"o7J4JM\":[\"筛选\"],\"o8va6N\":[\"恢复\"],\"ojKCLU\":[\"负责人\"],\"p9yTeb\":[\"排序:标题\"],\"pKztsX\":[\"在完整编辑器中打开\"],\"pnrmSP\":[\"新建卡片\"],\"pwN6Ae\":[\"折叠列\"],\"pzutoc\":[\"斜体\"],\"rdUucN\":[\"预览\"],\"sCzmvQ\":[\"张卡片\"],\"sQpDn6\":[\"退出全屏\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" 个冲突\",[\"1\"],\"待解决\"],\"u2IprG\":[\"卡片标题(回车添加,Esc 取消)\"],\"uAQUqI\":[\"状态\"],\"ucJg3u\":[\"泳道:状态\"],\"wf6Djn\":[\"斜体 (Ctrl+I)\"],\"wtw-au\":[\"设为完成列\"],\"wwu18a\":[\"图标\"],\"x52RAh\":[\"被 \",[\"blockedCount\"],\" 张未完成卡片阻塞\"],\"y1eoq1\":[\"复制链接\"],\"y9cj46\":[\"分组:优先级\"],\"yEbJGs\":[\"+ 添加字段\"],\"ybGQtY\":[\"← 返回列表\"],\"yz7wBu\":[\"关闭\"],\"yzF66j\":[\"链接\"],\"zOc0vf\":[\"无图标\"],\"zga9sT\":[\"确定\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"--lIxB\":[\"被阻塞于\"],\"-b7T3G\":[\"更新\"],\"1DBGsz\":[\"备注\"],\"1YABGm\":[\"链接 (Ctrl+K)\"],\"1hKEom\":[\"优先级\"],\"1lWHP7\":[\"不安全\"],\"2wxgft\":[\"重命名\"],\"3qkggm\":[\"全屏\"],\"4gdyen\":[\"本地(我的)\"],\"4hJhzz\":[\"表格\"],\"54sFiP\":[\"flowchart TD\\n A[开始] --> B[结束]\"],\"5Q_DQ6\":[\"行内代码\"],\"7VpPHA\":[\"确认\"],\"7s3WlU\":[\"阻塞\"],\"8PifYj\":[\"Mermaid 图表\"],\"8hSn0h\":[\"结果(可编辑)\"],\"8lE269\":[\"排序:手动\"],\"9gxam6\":[\"无法渲染此 Draw.io 图表。\"],\"AC9Gkf\":[\"展开列\"],\"AS5WO9\":[\"无法渲染此 PDF。\"],\"AVreQ5\":[\"拖动调整宽度\"],\"AgvHni\":[\"添加列\"],\"AxAubu\":[\"分组:负责人\"],\"BfMZ7w\":[\"接受云端\"],\"BnmEvM\":[\"存为模板\"],\"C6-ZRl\":[\"某人\"],\"EWPtMO\":[\"代码\"],\"EbMPZJ\":[\"未分配\"],\"G4qrLy\":[\"取消完成列\"],\"GKu3m4\":[\"暂无标签\"],\"Gpfctt\":[\"截止日期\"],\"H_SQFv\":[\"无颜色\"],\"I6SWEy\":[\"分栏\"],\"ICip_B\":[\"云端(远程)\"],\"Ik60OC\":[\"在编辑器中打开\"],\"Iw6WJa\":[\"设置 WIP 限制\"],\"JTYvAw\":[\"搜索卡片\"],\"K_F6pa\":[\"保存中…\"],\"KjXDqG\":[\"泳道:无\"],\"KmydK6\":[\"粗体\"],\"KvW1VO\":[\"Draw.io 图表\"],\"LQn6-8\":[\"接受本地\"],\"MHrjPM\":[\"标题\"],\"Mm72la\":[\"暂无评论\"],\"NBdIgR\":[\"评论\"],\"ONWvwQ\":[\"上传\"],\"OYHzN1\":[\"标签\"],\"OepdfE\":[\"分组:状态\"],\"Pvpx7b\":[\"粘贴 URL 或路径\"],\"Q2mGA7\":[\"清除筛选\"],\"QD8opX\":[\"看板\"],\"QlsPZy\":[\"输入 Mermaid 语法以查看图表。\"],\"S5Qbb1\":[\"用逗号分隔\"],\"TdfEV7\":[\"归档\"],\"UQOvxZ\":[\"空白卡片\"],\"VNa_N2\":[\"暂不支持预览此文件类型。\"],\"VbyRUy\":[\"评论\"],\"WSP6v1\":[\"排序:优先级\"],\"X03-eC\":[\"请输入内容。\"],\"XJOV1Y\":[\"活动\"],\"Ya7bZl\":[\"图表错误\"],\"Zot9XS\":[\"暂无卡片\"],\"_5CsXX\":[\"完成列\"],\"_EsjyQ\":[\"使用此版本\"],\"a6uhHr\":[\"粗体 (Ctrl+B)\"],\"aDvLhk\":[\"添加评论…\"],\"abUZlY\":[\"添加详情...\"],\"agOeRN\":[\"无法渲染此 API 规范。\"],\"b4hVKD\":[\"彩色列\"],\"cfaWH-\":[\"添加标签\"],\"cnGeoo\":[\"删除\"],\"d-F6q9\":[\"创建\"],\"d5z6xQ\":[\"WIP 限制 \",[\"0\"]],\"dEgA5A\":[\"取消\"],\"euc6Ns\":[\"复制卡片\"],\"fYcKtB\":[\"排序:截止\"],\"gANddk\":[\"上传中…\"],\"gLDJuJ\":[\"未命名卡片\"],\"hh4sEG\":[\"相关\"],\"hnK1gR\":[\"PDF 文档\"],\"i4_LY_\":[\"写作\"],\"iTylMl\":[\"模板\"],\"iYVqZq\":[\"列名称\"],\"jUbC3Z\":[\"泳道:优先级\"],\"jZlrte\":[\"颜色\"],\"kZlRKE\":[\"Mermaid 源码\"],\"kryGs-\":[\"卡片\"],\"lCF0wC\":[\"刷新\"],\"lHxVTh\":[\"泳道:负责人\"],\"ltF1xa\":[\"保存合并结果\"],\"nabda1\":[\"删除卡片\"],\"njJFtc\":[\"删除评论\"],\"o7J4JM\":[\"筛选\"],\"o8va6N\":[\"恢复\"],\"ojKCLU\":[\"负责人\"],\"p9yTeb\":[\"排序:标题\"],\"pKztsX\":[\"在完整编辑器中打开\"],\"pnrmSP\":[\"新建卡片\"],\"pwN6Ae\":[\"折叠列\"],\"pzutoc\":[\"斜体\"],\"rdUucN\":[\"预览\"],\"sCzmvQ\":[\"张卡片\"],\"sQpDn6\":[\"退出全屏\"],\"tK2x9T\":[\"⚠ \",[\"0\"],\" 个冲突\",[\"1\"],\"待解决\"],\"t_YqKh\":[\"移除\"],\"u2IprG\":[\"卡片标题(回车添加,Esc 取消)\"],\"uAQUqI\":[\"状态\"],\"ucJg3u\":[\"泳道:状态\"],\"w7E-FA\":[\"已拦截不安全链接:\",[\"url\"]],\"w_Sphq\":[\"附件\"],\"wf6Djn\":[\"斜体 (Ctrl+I)\"],\"wtw-au\":[\"设为完成列\"],\"wwu18a\":[\"图标\"],\"x52RAh\":[\"被 \",[\"blockedCount\"],\" 张未完成卡片阻塞\"],\"y1eoq1\":[\"复制链接\"],\"y9cj46\":[\"分组:优先级\"],\"yEbJGs\":[\"+ 添加字段\"],\"ybGQtY\":[\"← 返回列表\"],\"yz7wBu\":[\"关闭\"],\"yzF66j\":[\"链接\"],\"zOc0vf\":[\"无图标\"],\"zga9sT\":[\"确定\"]}"); \ No newline at end of file diff --git a/shared/i18n/locales/zh/messages.po b/shared/i18n/locales/zh/messages.po index 4214a8f..3af9044 100644 --- a/shared/i18n/locales/zh/messages.po +++ b/shared/i18n/locales/zh/messages.po @@ -70,6 +70,10 @@ msgstr "归档" msgid "Assignee" msgstr "负责人" +#: shared/components/board/BoardPeek.tsx +msgid "Attachments" +msgstr "附件" + #: shared/components/board/BoardSurface.tsx msgid "Blank card" msgstr "空白卡片" @@ -332,6 +336,10 @@ msgstr "在编辑器中打开" msgid "Open in full editor" msgstr "在完整编辑器中打开" +#: shared/components/board/BoardPeek.tsx +msgid "Paste a URL or path" +msgstr "粘贴 URL 或路径" + #: shared/components/viewers/PdfView.tsx msgid "PDF document" msgstr "PDF 文档" @@ -359,6 +367,10 @@ msgstr "刷新" msgid "Relates" msgstr "相关" +#: shared/components/board/BoardPeek.tsx +msgid "Remove" +msgstr "移除" + #: shared/components/board/BoardSurface.tsx msgid "Rename" msgstr "重命名" @@ -467,6 +479,14 @@ msgstr "标题" msgid "Unassigned" msgstr "未分配" +#: shared/components/board/BoardPeek.tsx +msgid "unsafe" +msgstr "不安全" + +#: shared/components/board/BoardPeek.tsx +msgid "Unsafe link blocked: {url}" +msgstr "已拦截不安全链接:{url}" + #: shared/components/board/BoardSurface.tsx msgid "Unset done column" msgstr "取消完成列" @@ -479,6 +499,14 @@ msgstr "未命名卡片" msgid "Updated" msgstr "更新" +#: shared/components/board/BoardPeek.tsx +msgid "Upload" +msgstr "上传" + +#: shared/components/board/BoardPeek.tsx +msgid "Uploading…" +msgstr "上传中…" + #: shared/components/ConflictResolver.tsx msgid "Use this" msgstr "使用此版本" diff --git a/shared/lib/board.ts b/shared/lib/board.ts index 2c223cf..0be0425 100644 --- a/shared/lib/board.ts +++ b/shared/lib/board.ts @@ -57,6 +57,8 @@ export type BoardViewCard = { taskDone?: number; taskTotal?: number; excerpt?: string | null; + /** Attachment URLs / vault paths (frontmatter `attachments`). */ + attachments?: string[]; /** Values for the board's user-defined custom fields, keyed by field key. */ custom?: Record; /** Card slugs this card is blocked by (frontmatter `blocked_by`). */ @@ -67,6 +69,44 @@ export type BoardViewCard = { relates?: string[]; }; +/** Parse a frontmatter `attachments` value (comma-separated URLs/paths) into a list. */ +export function parseAttachments(raw: string): string[] { + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** Serialize attachment URLs/paths back to a frontmatter value. */ +export function serializeAttachments(list: string[]): string { + return list.join(", "); +} + +/** The display name for an attachment: its last path segment (decoded). */ +export function attachmentName(url: string): string { + const last = url.split(/[\\/]/).pop() || url; + try { + return decodeURIComponent(last.split("?")[0] || last); + } catch { + return last; + } +} + +/** + * Whether an attachment value is safe to render as a clickable `href`. Blocks + * dangerous schemes (`javascript:`, `data:`, `vbscript:`, `file:`, …); allows + * http(s) and scheme-less relative/vault paths. An attachment may carry a + * user-supplied URL, so this guards against stored XSS via the link. + */ +export function isSafeAttachmentUrl(url: string): boolean { + const u = url.trim(); + if (!u) return false; + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(u); + if (!scheme) return true; // no scheme → relative path + const s = scheme[1]!.toLowerCase(); + return s === "http" || s === "https"; +} + /** Read the declared custom-field values out of a flat property/frontmatter map. */ export function pickCustomFields( props: Record | null | undefined, diff --git a/src/components/BoardView.tsx b/src/components/BoardView.tsx index cceea36..8c1d2aa 100644 --- a/src/components/BoardView.tsx +++ b/src/components/BoardView.tsx @@ -9,6 +9,7 @@ import type { BoardActions } from "@shared/components/board"; import { DEFAULT_DONE_COLUMN, pickCustomFields, + serializeAttachments, serializeLinks, slugify, type BoardViewCard, @@ -88,6 +89,7 @@ export function BoardView({ boardPath, boardRelativePath }: { boardPath: string; taskDone: c.taskDone, taskTotal: c.taskTotal, excerpt: c.excerpt ?? null, + attachments: c.attachments ?? [], custom: pickCustomFields(c.properties, config?.fields), blockedBy: c.blockedBy ?? [], blocks: c.blocks ?? [], @@ -176,6 +178,7 @@ export function BoardView({ boardPath, boardRelativePath }: { boardPath: string; if (patch.due !== undefined) next.due = patch.due ?? ""; if (patch.icon !== undefined) next.icon = patch.icon ?? ""; if (patch.tags !== undefined) next.tags = patch.tags.map((tg) => tg.label).join(", "); + if (patch.attachments !== undefined) next.attachments = serializeAttachments(patch.attachments); if (patch.custom !== undefined) for (const [k, v] of Object.entries(patch.custom)) next[k] = v ?? ""; if (patch.blockedBy !== undefined) next.blocked_by = serializeLinks(patch.blockedBy); if (patch.blocks !== undefined) next.blocks = serializeLinks(patch.blocks); diff --git a/src/lib/types.ts b/src/lib/types.ts index 7fcbccf..5755602 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -42,6 +42,7 @@ export type BoardCard = { taskTotal?: number; icon?: string | null; excerpt?: string | null; + attachments?: string[]; /** Full frontmatter map (for custom fields declared in the board config). */ properties?: Record; blockedBy?: string[]; diff --git a/tests/unit/boardAttachments.spec.ts b/tests/unit/boardAttachments.spec.ts new file mode 100644 index 0000000..935296d --- /dev/null +++ b/tests/unit/boardAttachments.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; +import { parseAttachments, serializeAttachments, attachmentName } from "../../shared/lib/board"; + +// Pure-logic acceptance for B3 attachments: round-trip + display name. The Rust +// parse_attachments (services/jtype-core/src/lib.rs) must stay in sync. + +test("parseAttachments splits comma-separated values, trims, drops empties", () => { + expect(parseAttachments("https://x.com/a.pdf, design/b.png")).toEqual(["https://x.com/a.pdf", "design/b.png"]); + expect(parseAttachments(" one ,, two ")).toEqual(["one", "two"]); + expect(parseAttachments("")).toEqual([]); +}); + +test("serializeAttachments round-trips with parseAttachments", () => { + const list = ["https://x.com/a.pdf", "notes/b.md"]; + expect(parseAttachments(serializeAttachments(list))).toEqual(list); + expect(serializeAttachments([])).toBe(""); +}); + +test("attachmentName is the decoded basename without query string", () => { + expect(attachmentName("https://cdn.example.com/files/spec.pdf")).toBe("spec.pdf"); + expect(attachmentName("design/mockups/home.png")).toBe("home.png"); + expect(attachmentName("https://x.com/a.pdf?token=abc")).toBe("a.pdf"); + expect(attachmentName("https://x.com/My%20Doc.pdf")).toBe("My Doc.pdf"); + expect(attachmentName("plain")).toBe("plain"); +}); + +test("isSafeAttachmentUrl blocks dangerous schemes, allows http(s) + relative", async () => { + const { isSafeAttachmentUrl } = await import("../../shared/lib/board"); + // safe + expect(isSafeAttachmentUrl("https://x.com/a.pdf")).toBe(true); + expect(isSafeAttachmentUrl("http://x.com/a.pdf")).toBe(true); + expect(isSafeAttachmentUrl("design/mock.png")).toBe(true); + expect(isSafeAttachmentUrl("/vault/a.md")).toBe(true); + // dangerous + expect(isSafeAttachmentUrl("javascript:alert(1)")).toBe(false); + expect(isSafeAttachmentUrl(" JavaScript:alert(1)")).toBe(false); + expect(isSafeAttachmentUrl("data:text/html,")).toBe(false); + expect(isSafeAttachmentUrl("vbscript:msgbox(1)")).toBe(false); + expect(isSafeAttachmentUrl("file:///etc/passwd")).toBe(false); + expect(isSafeAttachmentUrl("")).toBe(false); +});