Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions services/jtype-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,8 @@ pub struct BoardCardInfo {
pub task_total: i64,
pub icon: Option<String>,
pub excerpt: Option<String>,
/// Attachment URLs/paths (frontmatter `attachments`, comma-separated).
pub attachments: Vec<String>,
/// 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<String, String>,
Expand All @@ -1360,6 +1362,16 @@ pub struct BoardCardInfo {
pub relates: Vec<String>,
}

/// 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<String> {
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);
Expand Down Expand Up @@ -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(),
Expand Down
10 changes: 9 additions & 1 deletion services/jtype-web/frontend/src/pages/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).attachments)
? ((c.propertiesExtra as Record<string, unknown>).attachments as unknown[]).filter((x): x is string => typeof x === 'string')
: [],
custom: pickCustomFields(propsStr, view.fields),
}
})
Expand Down Expand Up @@ -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<string, unknown>) } : {}
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
}
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions services/jtype-web/frontend/src/pages/WebBoardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
DEFAULT_DONE_COLUMN,
bodyExcerpt,
countTasks,
parseAttachments,
parseLinks,
parseTagList,
pickCustomFields,
serializeAttachments,
serializeLinks,
slugify,
type BoardViewCard,
Expand Down Expand Up @@ -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) : [],
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Delete attachments when empty instead of serializing to "" (Line 232).

Writing an empty string for cleared attachments can produce inconsistent round-trips across consumers. Remove the key when list length is zero.

Suggested fix
-        if (patch.attachments !== undefined) next.attachments = serializeAttachments(patch.attachments)
+        if (patch.attachments !== undefined) {
+          if (patch.attachments.length > 0) next.attachments = serializeAttachments(patch.attachments)
+          else delete next.attachments
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (patch.attachments !== undefined) next.attachments = serializeAttachments(patch.attachments)
if (patch.attachments !== undefined) {
if (patch.attachments.length > 0) next.attachments = serializeAttachments(patch.attachments)
else delete next.attachments
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/jtype-web/frontend/src/pages/WebBoardView.tsx` at line 232, In the
WebBoardView.tsx file where attachments are serialized, the current code assigns
serialized attachments to next.attachments whenever patch.attachments is
defined, but this creates an empty string when the attachments list is cleared.
Instead of always serializing when defined, add an additional check to verify
the attachments array has items (length > 0) before calling serializeAttachments
and assigning to next.attachments. If the attachments list is empty, either omit
the assignment entirely or explicitly delete the next.attachments key to ensure
consistent handling of cleared attachments across consumers.

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)
Expand Down Expand Up @@ -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}
/>
Expand Down
83 changes: 81 additions & 2 deletions shared/components/board/BoardPeek.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,6 +22,7 @@ export function BoardPeek({
onAddField,
dependencyCards,
loadNotes,
onUploadAttachment,
loadComments,
addComment,
deleteComment,
Expand All @@ -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<string>;
onUploadAttachment?: (file: File) => Promise<string>;
loadComments?: (id: string) => Promise<BoardComment[]>;
addComment?: (id: string, body: string) => Promise<BoardComment>;
deleteComment?: (commentId: string) => Promise<void>;
Expand All @@ -57,6 +59,8 @@ export function BoardPeek({
const [draft, setDraft] = useState<BoardViewCard>(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<BoardComment[]>([]);
const [newComment, setNewComment] = useState("");
const [activity, setActivity] = useState<BoardActivityEvent[]>([]);
Expand Down Expand Up @@ -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]));
Expand Down Expand Up @@ -354,6 +374,65 @@ export function BoardPeek({
)}
</div>

<div className="mt-4">
<span className="text-xs font-medium text-brand-gray">
<Trans>Attachments</Trans>
</span>
{attachments.length > 0 && (
<div className="mt-1 space-y-1">
{attachments.map((url) => (
<div key={url} className="flex items-center gap-1.5 rounded border border-stone-200 px-2 py-1 text-xs">
<PaperClipIcon className="h-3.5 w-3.5 shrink-0 text-stone-400" />
{isSafeAttachmentUrl(url) ? (
<a href={url} target="_blank" rel="noreferrer" className="flex-1 truncate text-brand-dark hover:underline" title={url}>
{attachmentName(url)}
</a>
) : (
<span className="flex-1 truncate text-stone-500" title={t`Unsafe link blocked: ${url}`}>
{attachmentName(url)} <span className="text-red-500">({t`unsafe`})</span>
</span>
)}
Comment on lines +386 to +394

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject //host attachment URLs.

isSafeAttachmentUrl() still treats scheme-less //... values as safe, but browsers resolve them to another origin. That lets a pasted attachment bypass the unsafe-link block and stay clickable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/components/board/BoardPeek.tsx` around lines 386 - 394, The
isSafeAttachmentUrl() function currently allows scheme-less URLs (starting with
//) to be treated as safe, but browsers interpret these as protocol-relative
URLs that resolve to another origin, creating a security bypass. Update the
isSafeAttachmentUrl() function to explicitly reject URLs that start with //
before checking other validation logic, ensuring these scheme-less attachment
URLs are properly blocked and displayed as unsafe.

<button
type="button"
onClick={() => setAttachments(attachments.filter((a) => a !== url))}
title={t`Remove`}
className="rounded p-0.5 text-stone-400 hover:text-red-600"
>
<XMarkIcon className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5">
<form
className="flex-1"
onSubmit={(e) => {
e.preventDefault();
addAttachment(newAttach);
setNewAttach("");
}}
>
<input className={fieldCls} placeholder={t`Paste a URL or path`} value={newAttach} onChange={(e) => setNewAttach(e.target.value)} />
</form>
{onUploadAttachment && (
<label className="inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-xs text-stone-600 hover:border-brand/40 hover:text-brand-dark">
<ArrowUpTrayIcon className="h-3.5 w-3.5" />
{uploading ? <Trans>Uploading…</Trans> : <Trans>Upload</Trans>}
<input
type="file"
className="hidden"
disabled={uploading}
onChange={(e) => {
void handleUpload(e.target.files?.[0]);
e.target.value = "";
}}
Comment on lines +418 to +429

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle upload failures before discarding the promise.

handleUpload() can reject here, but the void call drops the promise. That turns an upload failure into an unhandled rejection and gives the user no feedback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/components/board/BoardPeek.tsx` around lines 418 - 429, The onChange
handler in the file input element is calling handleUpload() with a void
operator, which suppresses the returned promise and causes unhandled rejections
if the upload fails. Remove the void operator and add proper error handling to
catch any failures from handleUpload(). This can be done by either chaining a
.catch() method to handle errors, or wrapping the call in proper async/await
with try/catch. Ensure that upload failures are properly communicated to the
user instead of silently failing.

/>
</label>
)}
</div>
</div>

<div className="mt-4 flex items-center justify-between">
<span className="text-xs font-medium text-brand-gray">
<Trans>Notes</Trans>
Expand Down
2 changes: 2 additions & 0 deletions shared/components/board/BoardSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function BoardSurface({
assigneeOptions,
tagOptions,
loadNotes,
onUploadAttachment,
loadComments,
addComment,
deleteComment,
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions shared/components/board/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** Upload a file as a card attachment, returning its URL/path. Omit to allow only URL/path entry. */
onUploadAttachment?: (file: File) => Promise<string>;
/** Card comments (DB board). Supply all three + currentUser to enable the section. */
loadComments?: (cardId: string) => Promise<BoardComment[]>;
addComment?: (cardId: string, body: string) => Promise<BoardComment>;
Expand Down
2 changes: 1 addition & 1 deletion shared/i18n/locales/en/messages.mjs
Original file line number Diff line number Diff line change
@@ -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\"]}");
/*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\"]}");
Loading
Loading