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
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- 注册、登录、退出;每位用户有独立的文档与文件夹。
- 批量上传 PDF、PNG、JPG、WEBP 和 UTF-8 TXT;重复内容提示。
- 文档标题修改、文件夹归档、类型与处理状态筛选、分页。
- 多级文件夹(最多 8 层)、重命名、整体移动、面包屑导航和包含子目录的搜索。
- 有文字层的 PDF 直接提取;扫描 PDF 和图片通过 Tesseract OCR 识别。
- 后台队列、任务状态、失败后重试、中断任务恢复。
- 文件名和识别文字关键词搜索,展示匹配位置附近的摘要。
Expand Down Expand Up @@ -61,6 +62,18 @@ $env:TESSDATA_PREFIX = 'C:\Program Files\Tesseract-OCR\tessdata'

这些环境变量需在启动网页和 worker 的同一个终端中设置。若简体中文语言包未安装,英语模型无法保证中文识别质量。

### 按层级整理文档

在文件夹页面点击“新建文件夹”,默认在当前目录下创建;也可在窗口中选择其他上级目录。通过“重命名或移动”可以调整目录及其全部子目录,同一位置的名称必须唯一。上传和文档归档的下拉菜单显示完整路径,方便区分不同位置的同名目录。

文件夹页面默认只显示当前目录的文档;勾选“包含子文件夹”后,搜索、类型筛选和分页会覆盖整棵子目录。面包屑和子文件夹卡片支持在手机上逐级浏览。

![多级文件夹与包含子目录的搜索](docs/screenshots/nested-folders.png)

只能移除没有子文件夹的目录。文档(包括回收站中的文档)会保留在上级目录;移除顶层目录时归入“待归档”。重命名和移动只更新数据库中的归档关系,原文件不会改名或迁移。

从旧版升级时,先停止服务并备份完整数据目录。启动脚本会自动迁移数据库,已有文件夹保留为顶层目录。使用同名子目录后,不能直接退回旧版的单层目录数据库结构。

### 演示资料

```powershell
Expand Down Expand Up @@ -97,6 +110,23 @@ $env:FOLDERDOCS_TEST_OCR = '1'
.\.venv\Scripts\python.exe manage.py test
```

浏览器回归检查仅使用虚构资料。先安装 Playwright(`npm install --no-save playwright`,或通过 `NODE_PATH` 使用已有安装);Windows 默认使用已安装的 Edge,可设置 `PLAYWRIGHT_CHANNEL` 选择其他已安装的浏览器。

```powershell
# 新建独立测试数据目录,避免修改个人文档;下面的终端使用相同环境变量。
$env:FOLDERDOCS_DATA_DIR = "$PWD\qa-output\browser-data"
$env:FOLDERDOCS_TEST_ACCOUNT = "$env:FOLDERDOCS_DATA_DIR\account.json"
$env:FOLDERDOCS_TEST_URL = 'http://127.0.0.1:8766'
.\.venv\Scripts\python.exe manage.py migrate --noinput
.\.venv\Scripts\python.exe qa\prepare_demo.py
.\scripts\start.ps1 -Port 8766
# 在另一个配置相同环境变量的终端:
node qa\browser.cjs
node qa\folders.cjs
```

检查覆盖上传、识别、下载、回收站,以及多级目录创建、移动、递归搜索、文件内容保留和手机导航;截图写入 `docs/screenshots/`,账号密码留在被忽略的 `qa-output/` 中。

## 结构与数据

```text
Expand All @@ -116,9 +146,10 @@ data/ SQLite 数据库、密钥、原文件、预览图(不提交

## 第一版边界与后续计划

当前限制:每次 1–20 个文件、单个文件 25 MB、PDF 最多 100 页;扫描页仅对前 30 页执行 OCR(其余页会提示未识别),文字型页面仍提取全文。文件夹为单层。数据库中的文字保存最多 200 万字符。
当前限制:每次 1–20 个文件、单个文件 25 MB、PDF 最多 100 页;扫描页仅对前 30 页执行 OCR(其余页会提示未识别),文字型页面仍提取全文。文件夹最多 8 层,每个名称最多 80 个字符。数据库中的文字保存最多 200 万字符。

- [ ] 多级文件夹与共享文件夹。
- [x] 多级文件夹、重命名与整体移动。
- [ ] 共享文件夹。
- [ ] 有效期分享链接、撤销分享与邀请注册。
- [ ] NAS 原位扫描及增量索引。
- [ ] PostgreSQL 与专业全文检索。
Expand Down
18 changes: 17 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ SQLite + one Python worker keep the installation usable without Redis, Linux, or

The data directory, local secret, user uploads, native OCR environment, and demo credentials are excluded from Git. Startup binds only 127.0.0.1. Registration is open for local users in this milestone.

## Folder hierarchy

Folders have an optional parent within the same owner's hierarchy, with a maximum of eight levels (roots count as level one). Names are trimmed and unique among siblings; root names are separately constrained because their parent is NULL. A database check rejects self-parenting. Application validation rejects foreign parents, cycles and moves that would exceed the depth limit anywhere in the moved subtree.

Create, rename/move and delete requests begin an atomic transaction with a no-op write to the owner's user row before reading the hierarchy. This serializes SQLite hierarchy mutations and takes a row lock on databases with row-level locking, so concurrent moves validate against committed structure. The tree for navigation, paths, direct document counts and descendant filtering is built from one owner-scoped query.

Only leaf folders can be removed. Active and trashed documents are reparented to the removed folder's parent, or to the inbox for a root; their file paths, content, processing status and trash state are unchanged. The parent relation uses RESTRICT to protect direct folder deletion while allowing an entire owner cascade. Folder moves retain UUIDs and move descendants through their existing relations; they do not touch physical originals or previews.

The library defaults to direct-folder documents. `recursive=1` expands the selected folder filter to its owner-scoped descendants, including itself, before applying search, state, type and pagination. Sidebar counts remain direct counts. Complete paths distinguish same-name folders in upload and document move controls. Breadcrumbs and child tiles allow mobile navigation where the sidebar tree is hidden.

Migration 0002 preserves all existing folders as roots and leaves document associations and metadata intact. Back up the full data directory before upgrading; rollback to the old global name constraint is unsupported once duplicate names are used in different parents.

## Verified acceptance paths

- Text/PDF/image upload -> queued document -> worker -> searchable text and unmodified source.
Expand All @@ -29,7 +41,11 @@ The data directory, local secret, user uploads, native OCR environment, and demo
- Delete during processing -> lease invalidation -> discarded late result.
- Stale processing lease -> rescheduled task.
- Mutations require POST and CSRF protection.
- Existing single-level database -> upgraded roots and unchanged document metadata.
- Nested create -> rename/move subtree -> recursive search -> original bytes unchanged.
- Self/descendant/foreign parent or too-deep move -> rejection without partial update.
- Leaf removal -> active and trashed documents in parent; parent removal with children -> rejection.

## Deferred work

Nested folders, sharing, NAS indexing, automatic backup, scalable search and Linux/Docker deployment are separate milestones. Folder names currently organize database entries; they do not rename the original directory hierarchy.
Sharing, NAS indexing, automatic backup, scalable search and Linux/Docker deployment are separate milestones. Folder names organize database entries; they do not rename the original directory hierarchy.
Binary file modified docs/screenshots/library.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/nested-folders-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/nested-folders.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions documents/folders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Owner-scoped folder tree and serialized hierarchy mutations."""
from django.contrib.auth import get_user_model
from django.db.models import Count, F, Q
from .models import Folder


def lock_hierarchy(owner):
# First write in an atomic transaction: serializes SQLite mutations, and
# acquires the owner's row lock on databases with row-level locking.
get_user_model().objects.filter(pk=owner.pk).update(last_login=F('last_login'))


def folder_tree(owner):
rows = list(Folder.objects.filter(owner=owner).annotate(
document_count=Count('document', filter=Q(document__deleted_at__isnull=True))))
by_parent = {}
for item in rows:
by_parent.setdefault(item.parent_id, []).append(item)
result = []

def visit(parent, ancestors):
for item in by_parent.get(parent, []):
item.depth = len(ancestors)
item.child_count = len(by_parent.get(item.pk, []))
item.ancestors = ancestors
item.path = ' / '.join([node.name for node in ancestors] + [item.name])
item.descendant_ids = {item.pk}
result.append(item)
visit(item.pk, ancestors + [item])
for child in by_parent.get(item.pk, []):
item.descendant_ids.update(child.descendant_ids)

visit(None, [])
return result
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Generated by Django 5.2.18 on 2026-10-14 08:06

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("documents", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.RemoveConstraint(
model_name="folder",
name="unique_owner_folder",
),
migrations.AddField(
model_name="folder",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="children",
to="documents.folder",
),
),
migrations.AddConstraint(
model_name="folder",
constraint=models.UniqueConstraint(
condition=models.Q(("parent__isnull", True)),
fields=("owner", "name"),
name="unique_root_folder",
),
),
migrations.AddConstraint(
model_name="folder",
constraint=models.UniqueConstraint(
fields=("owner", "parent", "name"), name="unique_sibling_folder"
),
),
migrations.AddConstraint(
model_name="folder",
constraint=models.CheckConstraint(
condition=models.Q(("parent", models.F("id")), _negated=True),
name="folder_not_own_parent",
),
),
]
33 changes: 32 additions & 1 deletion documents/models.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,48 @@
import uuid
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models


class Folder(models.Model):
MAX_DEPTH = 8
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
parent = models.ForeignKey('self', on_delete=models.RESTRICT, null=True, blank=True, related_name='children')
name = models.CharField(max_length=80)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ['name']
constraints = [models.UniqueConstraint(fields=['owner', 'name'], name='unique_owner_folder')]
constraints = [
models.UniqueConstraint(fields=['owner', 'name'], condition=models.Q(parent__isnull=True), name='unique_root_folder'),
models.UniqueConstraint(fields=['owner', 'parent', 'name'], name='unique_sibling_folder'),
models.CheckConstraint(condition=~models.Q(parent=models.F('id')), name='folder_not_own_parent'),
]

def clean(self):
super().clean()
self.name = self.name.strip()
if not self.name or len(self.name) > 80:
raise ValidationError('文件夹名称需要 1–80 个字符。')
graph = dict(Folder.objects.filter(owner_id=self.owner_id).values_list('pk', 'parent_id'))
if self.parent_id and self.parent_id not in graph:
raise ValidationError('上级文件夹不存在或不属于当前用户。')
graph[self.pk] = self.parent_id
for node in graph:
seen = set()
current = node
while current is not None:
if current in seen:
raise ValidationError('不能将文件夹移动到自身或其子文件夹中。')
seen.add(current)
if len(seen) > self.MAX_DEPTH:
raise ValidationError(f'文件夹最多支持 {self.MAX_DEPTH} 层,请选择更靠近根目录的位置。')
if current not in graph:
raise ValidationError('文件夹层级无效。')
current = graph[current]
if Folder.objects.filter(owner_id=self.owner_id, parent_id=self.parent_id, name=self.name).exclude(pk=self.pk).exists():
raise ValidationError('同一位置已存在同名文件夹。')


def original_path(instance, filename):
Expand Down
Loading
Loading