Compare commits

...

2 commits

Author SHA1 Message Date
650869e2f7
server: ol/ul for html->mfm
Implements a simple rendering for ordered and unordered lists.

To properly support nested lists, and lists inside of blockquotes,
this necessitated rewriting the `analyze` function to return text.
This now also means that markup inside of blockquotes will be parsed properly.
2024-08-09 00:57:09 +02:00
2ed5ecd6a1
server: code improvements in following service 2024-08-09 00:25:12 +02:00
2 changed files with 111 additions and 98 deletions

View file

@ -23,13 +23,14 @@ export function fromHtml(html: string, quoteUri?: string | null): string {
html.replace(/<br\s?\/?>\r?\n/gi, '\n'),
);
let text = '';
// stores if we are parsing any lists.
// 0 for a level that is an unordered list, otherwise the counter for the ordered list
let listIndex: number[] = [];
for (const n of dom.childNodes) {
analyze(n);
}
return text.trim();
return dom.childNodes
.map(analyze)
.join('')
.trim();
function getText(node: TreeAdapter.Node): string {
if (treeAdapter.isTextNode(node)) return node.value;
@ -43,60 +44,55 @@ export function fromHtml(html: string, quoteUri?: string | null): string {
return '';
}
function appendChildren(childNodes: TreeAdapter.ChildNode[]): void {
if (childNodes.length > 0) {
for (const n of childNodes) {
analyze(n);
}
}
function analyzeMultiple(childNodes: TreeAdapter.ChildNode[]): string {
return childNodes.map(analyze).join('');
}
function analyze(node: TreeAdapter.Node): void {
function analyze(node: TreeAdapter.Node): string {
if (treeAdapter.isTextNode(node)) {
text += node.value;
return;
return node.value;
}
// Skip comment or document type node
if (!treeAdapter.isElementNode(node)) return;
if (!treeAdapter.isElementNode(node)) return '';
switch (node.nodeName) {
case 'br': {
text += '\n';
break;
return '\n';
}
case 'a':
{
let text = '';
// trim spaces away, because some AP servers (app.wafrn.net) send strange
// zero width non-break space in strange places and things like that
const txt = getText(node).trim();
const linkText = getText(node).trim();
const href = getAttr(node, 'href');
// hashtags
if (txt.startsWith('#') && href && (attrHas(node, 'rel', 'tag') || attrHas(node, 'class', 'hashtag'))) {
text += txt;
if (linkText.startsWith('#') && href && (attrHas(node, 'rel', 'tag') || attrHas(node, 'class', 'hashtag'))) {
text += linkText;
// mentions: a link that starts with `@` and does not include space
} else if (txt.startsWith('@') && txt.match(/\s/) == null && !attrHas(node, 'rel', 'me')) {
const part = txt.split('@');
} else if (linkText.startsWith('@') && linkText.match(/\s/) == null && !attrHas(node, 'rel', 'me')) {
const part = linkText.split('@');
if (part.length === 2 && href) {
// restore the host name part
const acct = `${txt}@${(new URL(href)).hostname}`;
const acct = `${linkText}@${(new URL(href)).hostname}`;
text += acct;
} else if (part.length === 3) {
text += txt;
text += linkText;
}
// other
} else {
const generateLink = () => {
if (!href && !txt) {
if (!href && !linkText) {
return '';
}
if (!href) {
return txt;
return linkText;
}
if (!txt || txt === href) { // #6383: Missing text node
if (!linkText || linkText === href) { // #6383: Missing text node
if (href.match(urlRegexFull)) {
return href;
} else {
@ -104,78 +100,57 @@ export function fromHtml(html: string, quoteUri?: string | null): string {
}
}
if (href.match(urlRegex) && !href.match(urlRegexFull)) {
return `[${txt}](<${href}>)`; // #6846
return `[${linkText}](<${href}>)`; // #6846
} else {
return `[${txt}](${href})`;
return `[${linkText}](${href})`;
}
};
text += generateLink();
}
break;
return text;
}
case 'h1':
{
text += '【';
appendChildren(node.childNodes);
text += '】\n';
break;
return '【' + analyzeMultiple(node.childNodes) + '】\n';
}
case 'b':
case 'strong':
{
text += '**';
appendChildren(node.childNodes);
text += '**';
break;
return '**' + analyzeMultiple(node.childNodes) + '**';
}
case 'small':
{
text += '<small>';
appendChildren(node.childNodes);
text += '</small>';
break;
return '<small>' + analyzeMultiple(node.childNodes) + '</small>';
}
case 's':
case 'del':
{
text += '~~';
appendChildren(node.childNodes);
text += '~~';
break;
return '~~' + analyzeMultiple(node.childNodes) + '~~';
}
case 'i':
case 'em':
{
text += '<i>';
appendChildren(node.childNodes);
text += '</i>';
break;
return '<i>' + analyzeMultiple(node.childNodes) + '</i>';
}
// block code (<pre><code>)
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
text += '\n```\n';
text += getText(node.childNodes[0]);
text += '\n```\n';
return '\n```\n' + getText(node.childNodes[0]) + '\n```\n';
} else {
appendChildren(node.childNodes);
return analyzeMultiple(node.childNodes);
}
break;
}
// inline code (<code>)
case 'code': {
text += '`';
appendChildren(node.childNodes);
text += '`';
break;
return '`' + analyzeMultiple(node.childNodes) + '`';
}
// inline or block KaTeX
@ -207,31 +182,27 @@ export function fromHtml(html: string, quoteUri?: string | null): string {
// can be rendered as KaTeX, now decide if it is possible to render as inline or not
if (/[\r\n]/.test(formula)) {
// line break, this must be rendered as a block
text += '\n\\[' + formula + '\\]\n';
return '\n\\[' + formula + '\\]\n';
} else {
// render as inline
text += '\\(' + formula + '\\)';
return '\\(' + formula + '\\)';
}
} else {
// not KaTeX, but if there is a plaintext annotation it can still be rendered as code
if (/[\r\n]/.test(formula)) {
// line break, this must be rendered as a block
text += '\n```\n' + formula + '\n```\n';
return '\n```\n' + formula + '\n```\n';
} else {
// render as inline
text += '`' + formula + '`';
return '`' + formula + '`';
}
}
break;
}
case 'blockquote': {
const t = getText(node);
if (t) {
text += '\n> ';
text += t.split('\n').join('\n> ');
}
break;
return analyzeMultiple(node.childNodes)
.trim()
.replace(/^|\n/g, '\n>');
}
case 'p':
@ -241,9 +212,50 @@ export function fromHtml(html: string, quoteUri?: string | null): string {
case 'h5':
case 'h6':
{
text += '\n\n';
appendChildren(node.childNodes);
break;
return '\n\n' + analyzeMultiple(node.childNodes);
}
// lists and list items
case 'ol':
case 'ul':
{
if (node.nodeName == 'ol') {
listIndex.push(1);
} else {
listIndex.push(0);
}
let text = '\n' + analyzeMultiple(node.childNodes);
listIndex.pop();
return text;
}
case 'li':
{
if (listIndex.length == 0) {
break;
}
let text = '\n';
// pop the current operating on index for manipulation
let index = listIndex.pop();
// indent the start of the list item respecitve of the level of
// nesting of lists
//
// since we popped the current index, the length will be 0 on
// the first level, thus causing no indent on the first level
text += ' '.repeat(listIndex.length);
if (index == 0) {
text += '- ';
} else {
text += index + ') ';
index++;
}
// done with the index, put it back so nested lists with
// analyzeMultiple will work correctly
listIndex.push(index);
text += analyzeMultiple(node.childNodes);
return text;
}
// other block elements
@ -251,30 +263,28 @@ export function fromHtml(html: string, quoteUri?: string | null): string {
case 'header':
case 'footer':
case 'article':
case 'li':
case 'dt':
case 'dd':
{
text += '\n';
appendChildren(node.childNodes);
break;
return '\n' + analyzeMultiple(node.childNodes);
}
case 'span':
{
if (attrHas(node, 'class', 'quote-inline') && quoteUri && getText(node).trim() === `RE: ${quoteUri}`) {
// embedded quote thingy for backwards compatibility, don't show it
return '';
} else {
appendChildren(node.childNodes);
return analyzeMultiple(node.childNodes);
}
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);
break;
return analyzeMultiple(node.childNodes);
}
}
return '';
}
}

View file

@ -51,7 +51,7 @@ export async function insertFollowingDoc(followee: { id: User['id']; host: User[
followerId: follower.id,
});
if (requested) {
if (requested > 0) {
await FollowRequests.delete({
followeeId: followee.id,
followerId: follower.id,
@ -132,7 +132,7 @@ export default async function(_follower: { id: User['id'] }, _followee: { id: Us
]);
// check blocking
const [blocking, blocked] = await Promise.all([
const [sender_block, receiver_blocks] = await Promise.all([
Blockings.findOneBy({
blockerId: follower.id,
blockeeId: followee.id,
@ -143,30 +143,33 @@ export default async function(_follower: { id: User['id'] }, _followee: { id: Us
}),
]);
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocked) {
// リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && receiver_blocks) {
// remote actor is trying to follow a local user which blocks them
// -> follow request is immediately rejected
const content = renderActivity(renderReject(renderFollow(follower, followee, requestId), followee));
deliver(followee , content, follower.inbox);
deliver(followee, content, follower.inbox);
return;
} else if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocking) {
// リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
await Blockings.delete(blocking.id);
} else if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && sender_block) {
// receiving a follow from a remote user that supposedly blocks the followee
// this is not possible, so the block must be in error
// -> delete the block
await Blockings.delete(sender_block.id);
} else {
// それ以外は単純に例外
if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
// everything else can just be an error on our side
if (sender_block != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
if (receiver_blocks != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
}
const followeeProfile = await UserProfiles.findOneByOrFail({ userId: followee.id });
// フォロー対象が鍵アカウントである or
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
// The followee is a locked account or
// The follower is a bot and the followee is cautios about being followed by bots or
// The follower is local and the followee is remote
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (Users.isLocalUser(follower) && Users.isRemoteUser(followee))) {
// Can not follow immediately, create a follow request instead.
let autoAccept = false;
// Even for locked accounts, if they are already following, go through immediately.
// If the follow already exists, nothing more needs to be done.
const following = await Followings.countBy({
followerId: follower.id,
followeeId: followee.id,