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
32 changes: 22 additions & 10 deletions app/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ReactMarkdown from 'react-markdown';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import TaskRef from '@/components/TaskRef';
import { TASK_REF_SCHEME, remarkTaskRefs } from '@/lib/task-refs';

/** Quita la prop `node` que react-markdown pasa a cada renderer, para no
* esparcirla sobre el elemento del DOM. */
Expand All @@ -15,21 +17,31 @@ function withoutNode<T extends { node?: unknown }>(props: T): Omit<T, 'node'> {
* `<script>` ni otros tags embebidos, así que el texto del usuario no puede
* inyectar marcado. Los enlaces abren en pestaña nueva y sin referrer.
* `remark-breaks` mantiene los saltos de línea de los textos planos previos.
* Las referencias `[[CASA-1]]` se pintan como chip que abre la tarea citada.
*/
export default function Markdown({ children }: { children: string }) {
return (
<div className="markdown text-[15px] leading-relaxed break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm, remarkBreaks, remarkTaskRefs]}
// El saneador por defecto descarta protocolos desconocidos; el nuestro
// es interno (nunca sale a red), así que se permite explícitamente.
urlTransform={(url) => (url.startsWith(TASK_REF_SCHEME) ? url : defaultUrlTransform(url))}
components={{
a: (props) => (
<a
{...withoutNode(props)}
className="text-brand hover:underline"
target="_blank"
rel="noopener noreferrer"
/>
),
a: (props) => {
const href = props.href ?? '';
if (href.startsWith(TASK_REF_SCHEME)) {
return <TaskRef shortId={href.slice(TASK_REF_SCHEME.length)} />;
}
return (
<a
{...withoutNode(props)}
className="text-brand hover:underline"
target="_blank"
rel="noopener noreferrer"
/>
);
},
p: (props) => <p className="mb-2 last:mb-0" {...withoutNode(props)} />,
ul: (props) => <ul className="mb-2 list-disc pl-5" {...withoutNode(props)} />,
ol: (props) => <ol className="mb-2 list-decimal pl-5" {...withoutNode(props)} />,
Expand Down
28 changes: 28 additions & 0 deletions app/src/components/TaskRef.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next';
import { useData } from '@/data/data-context';
import { useTaskModal } from '@/components/modal-context';

/**
* Chip de una referencia `[[ID]]`. Abre la tarea citada. Si el id no
* corresponde a ninguna tarea visible para el usuario, pinta el texto literal
* en lugar de un enlace muerto.
*/
export default function TaskRef({ shortId }: { shortId: string }) {
const { t } = useTranslation();
const data = useData();
const { openTask } = useTaskModal();
const task = data.getTaskByShortId(shortId);
if (!task) return <>{`[[${shortId}]]`}</>;
const id = task.short_id ?? shortId;
return (
<button
type="button"
onClick={() => openTask(task.id)}
title={task.title}
aria-label={t('task.refOpen', { id, title: task.title })}
className="tnum inline-flex items-center rounded-md border border-app bg-surface2 px-1.5 py-0.5 align-baseline text-[12px] text-muted hover:border-brand hover:text-brand"
>
{id}
</button>
);
}
4 changes: 4 additions & 0 deletions app/src/data/DataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
getTasks: () => getBootstrapData()?.tasks ?? [],
getProject: (id) => getBootstrapData()?.projects.find((p) => p.id === id),
getTask: (id) => getBootstrapData()?.tasks.find((t) => t.id === id),
getTaskByShortId: (shortId) => {
const needle = shortId.trim().toUpperCase();
return getBootstrapData()?.tasks.find((t) => t.short_id?.toUpperCase() === needle);
},
getUsername: (id) => getBootstrapData()?.users.find((u) => u.id === id)?.username ?? '?',
getTaskDetail: (id) => {
const cached = detailCache.current.get(id);
Expand Down
2 changes: 2 additions & 0 deletions app/src/data/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface DataApi {
getTasks: () => Task[];
getProject: (id: string) => Project | undefined;
getTask: (id: string) => Task | undefined;
/** Busca por id corto legible (`CASA-1`), sin distinguir mayúsculas. */
getTaskByShortId: (shortId: string) => Task | undefined;
getUsername: (id: string | null | undefined) => string;

/** Detalle perezoso: devuelve el último conocido y dispara fetch si falta. */
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"deleteTitle": "Delete task",
"copyId": "Copy ID",
"idCopied": "Copied",
"refOpen": "Open task {{id}}: {{title}}",
"deleteHint": "Its comments and attachments will also be deleted.",
"deleteConfirm": "Are you sure? Press to confirm",
"deleting": "Deleting…",
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"deleteTitle": "Eliminar tarea",
"copyId": "Copiar ID",
"idCopied": "Copiado",
"refOpen": "Abrir la tarea {{id}}: {{title}}",
"deleteHint": "Se eliminarán también sus comentarios y adjuntos.",
"deleteConfirm": "¿Seguro? Pulsa para confirmar",
"deleting": "Eliminando…",
Expand Down
78 changes: 78 additions & 0 deletions app/src/lib/task-refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Referencias entre tareas: `[[CASA-1]]` dentro de texto Markdown se convierte
* en un enlace interno. El plugin solo toca nodos de texto, así que el código
* en línea y los bloques de código quedan intactos y se pueden documentar.
*/

/** Id corto: prefijo alfanumérico (1-8) + guion + número (1-6 dígitos). */
const TASK_REF_SOURCE = '\\[\\[([A-Za-z0-9]{1,8}-[0-9]{1,6})\\]\\]';

/** Esquema del href interno que Markdown.tsx intercepta para pintar el chip. */
export const TASK_REF_SCHEME = 'deltos-task:';

interface MdNode {
type: string;
value?: string;
children?: MdNode[];
url?: string;
title?: string | null;
}

/** Ids cortos citados en un texto, en orden de aparición y sin repetir. */
export function extractTaskRefs(text: string): string[] {
const found: string[] = [];
for (const m of text.matchAll(new RegExp(TASK_REF_SOURCE, 'g'))) {
if (!found.includes(m[1])) found.push(m[1]);
}
return found;
}

/** Parte un texto en texto plano + nodos de enlace interno. null si no hay. */
function splitText(value: string): MdNode[] | null {
const re = new RegExp(TASK_REF_SOURCE, 'g');
const nodes: MdNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(value)) !== null) {
if (m.index > last) nodes.push({ type: 'text', value: value.slice(last, m.index) });
nodes.push({
type: 'link',
url: `${TASK_REF_SCHEME}${m[1]}`,
title: null,
children: [{ type: 'text', value: m[1] }],
});
last = m.index + m[0].length;
}
if (nodes.length === 0) return null;
if (last < value.length) nodes.push({ type: 'text', value: value.slice(last) });
return nodes;
}

/**
* Sustituye las referencias de los nodos de texto del árbol. No entra en
* enlaces ya existentes (evitaría anidar <a>) ni en código, que no tiene hijos.
*/
function walk(node: MdNode): void {
if (!Array.isArray(node.children)) return;
if (node.type === 'link' || node.type === 'linkReference') return;
const next: MdNode[] = [];
for (const child of node.children) {
if (child.type === 'text' && typeof child.value === 'string') {
const parts = splitText(child.value);
if (parts) {
next.push(...parts);
continue;
}
}
walk(child);
next.push(child);
}
node.children = next;
}

/** Plugin remark: convierte `[[ID]]` en enlaces internos `deltos-task:ID`. */
export function remarkTaskRefs() {
return (tree: unknown) => {
walk(tree as MdNode);
};
}
Loading