Skip to content

✨(frontend) Interlinking doc #904

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 26 commits into
base: feature/doc-dnd
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
67b69d0
🚩(backend) add homepage feature flag
AntoLC Apr 9, 2025
e9ab099
🚩(frontend) integrate homepage feature flag
AntoLC Apr 9, 2025
ecd0656
🚚(frontend) Display homepage on /home url
AntoLC Apr 10, 2025
419079a
🚸(backend) make document search on title accent-insensitive
sampaccoud Apr 17, 2025
101cef7
♻️(frontend) refacto useCunninghamTheme
AntoLC Apr 22, 2025
3bf33d2
⚡️(frontend) reduce unblocking time for config
AntoLC Apr 22, 2025
4307b4f
🐛(backend) race condition create doc
AntoLC Feb 12, 2025
cdafe6f
📝(readme) update xl packages info (#885)
virgile-dev Apr 22, 2025
5268699
⬆️(dependencies) update js dependencies
AntoLC Apr 23, 2025
b90c537
🐛(back) keep info if document has deleted children
PanchoutNathan Mar 17, 2025
9eadaf3
➕(frontend) updated dependencies and added new packages
PanchoutNathan Mar 17, 2025
4ec3f42
✨(frontend) Added drag-and-drop functionality for document management
PanchoutNathan Mar 17, 2025
b9da03b
✨(frontend) added subpage management and document tree features
PanchoutNathan Mar 17, 2025
585d39d
✨(frontend) added new features for document management
PanchoutNathan Mar 27, 2025
ae4a433
🔥(frontend) silent next.js error
AntoLC Mar 31, 2025
09978ff
✏️(frontend) child document with different wording
AntoLC Apr 2, 2025
a551298
test-feature
AntoLC Apr 2, 2025
bfa06af
save
AntoLC Apr 10, 2025
2ee842c
fixup! 🐛(back) keep info if document has deleted children
AntoLC Apr 23, 2025
eaf5e22
fixup! ✨(frontend) added subpage management and document tree features
AntoLC Apr 23, 2025
fa40253
🚚(frontend) reduce features coupling
AntoLC Apr 23, 2025
177a07c
✨(frontend) create page from slash menu
AntoLC Apr 23, 2025
17a4573
✨(frontend) interlinking custom inline content
AntoLC Apr 23, 2025
57e5a19
✨(frontend) create page from dropdown search
AntoLC Apr 25, 2025
e6ad7d3
✨(frontend) create editor shortcuts hook
AntoLC Apr 27, 2025
87cf9dc
✨(frontend) interlinking export
AntoLC Apr 29, 2025
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
1 change: 1 addition & 0 deletions .github/workflows/docker-hub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
push:
branches:
- 'main'
- 'feature/doc-dnd'
tags:
- 'v*'
pull_request:
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ and this project adheres to

## [Unreleased]

## Added

- 🚸(backend) make document search on title accent-insensitive #874
- 🚩 add homepage feature flag #861
- ✨(frontend) multi-pages #701

## Changed

⚡️(frontend) reduce unblocking time for config #867

## [3.1.0] - 2025-04-07

## Added
Expand Down Expand Up @@ -136,6 +146,10 @@ and this project adheres to
- 🐛(email) invitation emails in receivers language


## Fixed

- 🐛(backend) race condition create doc #633

## [2.2.0] - 2025-02-10

## Added
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ Welcome to Docs! The open source document editor where your notes can become kno

## Why use Docs ❓

⚠️ **Note that Docs provides docs/pdf exporters by loading [two BlockNote packages](https://github.com/suitenumerique/docs/blob/main/src/frontend/apps/impress/package.json#L22C7-L23C53), which we use under the AGPL-3.0 licence. Until we comply with the terms of this license, we recommend that you don't run Docs as a commercial product, unless you are willing to sponsor [BlockNote](https://github.com/TypeCellOS/BlockNote).**

Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.

### Write
Expand All @@ -39,11 +37,13 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 🤝 Collaborate with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 05/2025`

### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence

⚠️ For the PDF and Docx export Docs relies on XL packages from BlockNote licenced in AGPL-3.0. Please make sure you fulfill your obligations regarding BlockNote licensing (see https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE and https://www.blocknotejs.org/about#partner-with-us).

## Getting started 🔧

### Test it
Expand Down Expand Up @@ -118,6 +118,7 @@ $ make run-backend
```

**Adding content**

You can create a basic demo site by running:

```shellscript
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
- "1081:1080"

minio:
user: ${DOCKER_USER:-1000}
# user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
Expand Down
5 changes: 5 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ These are the environmental variables you can set for the impress-backend contai
| COLLABORATION_API_URL | collaboration api host | |
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
| COLLABORATION_WS_URL | collaboration websocket url | |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
| FRONTEND_THEME | frontend theme to use | |
| POSTHOG_KEY | posthog key for analytics | |
| CRISP_WEBSITE_ID | crisp website id for support | |
Expand Down
1 change: 1 addition & 0 deletions env.d/development/common.dist
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,6 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/

# Frontend
FRONTEND_THEME=default
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True
FRONTEND_FOOTER_FEATURE_ENABLED=True
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json
37 changes: 34 additions & 3 deletions src/backend/core/api/filters.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,50 @@
"""API filters for Impress' core application."""

import unicodedata

from django.utils.translation import gettext_lazy as _

import django_filters

from core import models


def remove_accents(value):
"""Remove accents from a string (vélo -> velo)."""
return "".join(
c
for c in unicodedata.normalize("NFD", value)
if unicodedata.category(c) != "Mn"
)


class AccentInsensitiveCharFilter(django_filters.CharFilter):
"""
A custom CharFilter that filters on the accent-insensitive value searched.
"""

def filter(self, qs, value):
"""
Apply the filter to the queryset using the unaccented version of the field.

Args:
qs: The queryset to filter.
value: The value to search for in the unaccented field.
Returns:
A filtered queryset.
"""
if value:
value = remove_accents(value)
return super().filter(qs, value)


class DocumentFilter(django_filters.FilterSet):
"""
Custom filter for filtering documents.
Custom filter for filtering documents on title (accent and case insensitive).
"""

title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
title = AccentInsensitiveCharFilter(
field_name="title", lookup_expr="unaccent__icontains", label=_("Title")
)

class Meta:
Expand Down
27 changes: 25 additions & 2 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import connection, transaction
from django.db import models as db
from django.db import transaction
from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
Expand Down Expand Up @@ -607,6 +607,14 @@ def retrieve(self, request, *args, **kwargs):
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""

# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)

obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
Expand Down Expand Up @@ -666,10 +674,19 @@ def trashbin(self, request, *args, **kwargs):
permission_classes=[],
url_path="create-for-owner",
)
@transaction.atomic
def create_for_owner(self, request):
"""
Create a document on behalf of a specified owner (pre-existing user or invited).
"""

# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)

# Deserialize and validate the data
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
if not serializer.is_valid():
Expand Down Expand Up @@ -775,7 +792,12 @@ def children(self, request, *args, **kwargs):
serializer.is_valid(raise_exception=True)

with transaction.atomic():
child_document = document.add_child(
# "select_for_update" locks the table to ensure safe concurrent access
locked_parent = models.Document.objects.select_for_update().get(
pk=document.pk
)

child_document = locked_parent.add_child(
creator=request.user,
**serializer.validated_data,
)
Expand Down Expand Up @@ -1692,6 +1714,7 @@ def get(self, request):
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_FOOTER_FEATURE_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
Expand Down
10 changes: 10 additions & 0 deletions src/backend/core/migrations/0021_activate_unaccent_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.contrib.postgres.operations import UnaccentExtension
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"),
]

operations = [UnaccentExtension()]
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-03-14 14:03

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0021_activate_unaccent_extension"),
]

operations = [
migrations.AddField(
model_name="document",
name="has_deleted_children",
field=models.BooleanField(default=False),
),
]
11 changes: 10 additions & 1 deletion src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ def get_queryset(self):
return self._queryset_class(self.model).order_by("path")


# pylint: disable=too-many-public-methods
class Document(MP_Node, BaseModel):
"""Pad document carrying the content."""

Expand All @@ -486,6 +487,7 @@ class Document(MP_Node, BaseModel):
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
has_deleted_children = models.BooleanField(default=False)
duplicated_from = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
Expand Down Expand Up @@ -561,6 +563,12 @@ def save(self, *args, **kwargs):
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)

def is_leaf(self):
"""
:returns: True if the node is has no children
"""
return not self.has_deleted_children and self.numchild == 0

@property
def key_base(self):
"""Key base of the location where the document is stored in object storage."""
Expand Down Expand Up @@ -945,7 +953,8 @@ def soft_delete(self):

if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") - 1
numchild=models.F("numchild") - 1,
has_deleted_children=True,
)

# Mark all descendants as soft deleted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Tests for Documents API endpoint in impress's core app: children create
"""

from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4

import pytest
Expand Down Expand Up @@ -249,3 +250,41 @@ def test_api_documents_children_create_force_id_existing():
assert response.json() == {
"id": ["A document with this ID already exists. You cannot override it."]
}


@pytest.mark.django_db(transaction=True)
def test_api_documents_create_document_children_race_condition():
"""
It should be possible to create several documents at the same time
without causing any race conditions or data integrity issues.
"""

user = factories.UserFactory()

client = APIClient()
client.force_login(user)

document = factories.DocumentFactory()

factories.UserDocumentAccessFactory(user=user, document=document, role="owner")

def create_document():
return client.post(
f"/api/v1.0/documents/{document.id}/children/",
{
"title": "my child",
},
)

with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_document)
future2 = executor.submit(create_document)

response1 = future1.result()
response2 = future2.result()

assert response1.status_code == 201
assert response2.status_code == 201

document.refresh_from_db()
assert document.numchild == 2
31 changes: 31 additions & 0 deletions src/backend/core/tests/documents/test_api_documents_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Tests for Documents API endpoint in impress's core app: create
"""

from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4

import pytest
Expand Down Expand Up @@ -51,6 +52,36 @@ def test_api_documents_create_authenticated_success():
assert document.accesses.filter(role="owner", user=user).exists()


@pytest.mark.django_db(transaction=True)
def test_api_documents_create_document_race_condition():
"""
It should be possible to create several documents at the same time
without causing any race conditions or data integrity issues.
"""

def create_document(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/documents/",
{
"title": title,
},
format="json",
)

with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_document, "my document 1")
future2 = executor.submit(create_document, "my document 2")

response1 = future1.result()
response2 = future2.result()

assert response1.status_code == 201
assert response2.status_code == 201


def test_api_documents_create_authenticated_title_null():
"""It should be possible to create several documents with a null title."""
user = factories.UserFactory()
Expand Down
Loading