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
2 changes: 1 addition & 1 deletion app/frontend/src/components/TidyPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface TidyBatch {
id: string;
action: TidyAction;
tag: Tag | null;
rule: { id: string; titleContains: string } | null;
rule: { id: string; titleContains: string; fileType: string | null } | null;
itemCount: number;
remaining: number;
createdAt: string;
Expand Down
10 changes: 10 additions & 0 deletions app/frontend/src/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,13 @@ export function FilePreview({ item }: { item: Item }) {
<span aria-hidden="true" className="h-14 w-14 shrink-0 rounded-lg border border-line bg-surface" />
);
}

export const fileTypeNames: Record<string, string> = {
image: 'Images',
pdf: 'PDFs',
document: 'Documents',
spreadsheet: 'Spreadsheets',
presentation: 'Presentations',
archive: 'Zip archives',
text: 'Text files',
};
4 changes: 2 additions & 2 deletions app/frontend/src/pages/TidyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EmptyState, ListSkeleton, PageHeader, Tabs, timeAgo } from '@project-gr
import { useAuth } from '../auth';
import { Choice, Suggestion } from '../components/TidyList';
import { batchSummary, itemsText, TidyPreview, undoSummary, type TidyAction, type TidyBatch, type TidyRequest } from '../components/TidyPreview';
import { fileSize } from '../files';
import { fileSize, fileTypeNames } from '../files';
import { useSpaces } from '../spaces';
import { money, shortDate } from '../subscriptions';
import { useAction } from '../useAction';
Expand Down Expand Up @@ -257,7 +257,7 @@ function TidyHistory() {
<p className="m-0 text-ink">{batchSummary(batch)}</p>
<p className="mono-sm mt-1 mb-0 text-faint">
{timeAgo(batch.createdAt)}
{batch.rule && ` · by the rule “${batch.rule.titleContains}”`}
{batch.rule && ` · by the rule “${batch.rule.titleContains || fileTypeNames[batch.rule.fileType ?? '']}”`}
{batch.undoneAt
? ' · undone'
: batch.remaining < batch.itemCount && ` · ${itemsText(batch.itemCount - batch.remaining)} since deleted`}
Expand Down
40 changes: 32 additions & 8 deletions app/frontend/src/pages/spaces/TidyRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from 'react';
import { ConfirmDialog, ListSkeleton, TagChip, TextField } from '@project-graphite/ui';
import { useAuth } from '../../auth';
import { batchSummary, TidyPreview, type TidyRequest } from '../../components/TidyPreview';
import { fileTypeNames } from '../../files';
import type { Tag } from '../../spaces';
import { useAction } from '../../useAction';
import { useResource } from '../../useResource';
Expand All @@ -14,6 +15,7 @@ interface Rule {
spaceId: string;
titleContains: string;
kind: string | null;
fileType: string | null;
tag: Tag;
enabled: boolean;
createdAt: string;
Expand All @@ -29,6 +31,12 @@ const kindNames: Record<string, string> = {
file: 'files',
};

function ruleText(rule: Rule) {
const words = rule.titleContains && `“${rule.titleContains}” in the title`;
if (rule.fileType) return `${fileTypeNames[rule.fileType]}${words ? ` with ${words}` : ''}`;
return `Title contains “${rule.titleContains}”${rule.kind ? `, ${kindNames[rule.kind]} only` : ''}`;
}

export function TidyRules({ tags }: { tags: Tag[] }) {
const auth = useAuth();
const space = useSpace();
Expand All @@ -45,7 +53,7 @@ export function TidyRules({ tags }: { tags: Tag[] }) {
<div>
<h2 className="m-0 text-xl font-medium">Tagging rules</h2>
<p className="mt-2 mb-0 text-sm text-muted">
Tag new items in this space by words in their title.
Tag new items in this space by words in their title, by file type, or both.
</p>
</div>
{rules.error ? (
Expand All @@ -59,7 +67,7 @@ export function TidyRules({ tags }: { tags: Tag[] }) {
<li className="flex flex-wrap items-center justify-between gap-4 border-b border-line-soft py-3" key={rule.id}>
<div className="min-w-0">
<p className="m-0 flex flex-wrap items-center gap-2 text-ink">
Title contains “{rule.titleContains}”{rule.kind && `, ${kindNames[rule.kind]} only`} → <TagChip color={rule.tag.color} label={rule.tag.name} />
{ruleText(rule)} → <TagChip color={rule.tag.color} label={rule.tag.name} />
</p>
<p className="mono-sm mt-1 mb-0 text-faint">{rule.enabled ? 'On' : 'Paused'}</p>
</div>
Expand Down Expand Up @@ -118,27 +126,43 @@ export function TidyRules({ tags }: { tags: Tag[] }) {
onSubmit={(event) => {
event.preventDefault();
const target = event.currentTarget;
if (!form.check(target, { titleContains: [required('Enter the words to look for.'), atMost(100, 'Use at most 100 characters.')] })) return;
const values = new FormData(target);
const fileType = values.get('fileType');
const limit = atMost(100, 'Use at most 100 characters.');
if (!form.check(target, { titleContains: fileType ? [limit] : [required('Enter words to look for, or choose a file type.'), limit] })) return;
void creating
.run(async () => {
const created = await auth.request<Rule>(`/spaces/${space.id}/tidy-rules`, {
method: 'POST',
body: JSON.stringify({
titleContains: String(values.get('titleContains')).trim(),
tagId: values.get('tagId'),
...(values.get('kind') ? { kind: values.get('kind') } : {}),
...(fileType ? { fileType } : values.get('kind') ? { kind: values.get('kind') } : {}),
}),
});
rules.mutate((current) => [...current, created]);
return `New items with “${created.titleContains}” in the title will be tagged ${created.tag.name}.`;
const words = created.titleContains && `“${created.titleContains}” in the title`;
return created.fileType
? `${fileTypeNames[created.fileType]} uploaded from now on${words ? ` with ${words}` : ''} will be tagged ${created.tag.name}.`
: `New items with ${words} will be tagged ${created.tag.name}.`;
}, 'Could not add the rule')
.then((added) => added && target.reset());
}}
>
<h3 className="m-0 text-base font-medium">New rule</h3>
<TextField label="Title contains" maxLength={100} {...form.field('titleContains')} />
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-3">
<label className="field-label">
File type
<select defaultValue="" name="fileType">
<option value="">Any</option>
{Object.entries(fileTypeNames).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label className="field-label">
Kind of item
<select defaultValue="" name="kind">
Expand Down Expand Up @@ -177,9 +201,9 @@ export function TidyRules({ tags }: { tags: Tag[] }) {
await auth.request(`/tidy-rules/${deleting.id}`, { method: 'DELETE' });
rules.mutate((current) => current.filter((rule) => rule.id !== deleting.id));
}}
title={`Delete the rule for “${deleting.titleContains}”?`}
title="Delete this rule?"
>
Items it already tagged keep the tag.
{ruleText(deleting)}. Items it already tagged keep the tag.
</ConfirmDialog>
)}

Expand Down
33 changes: 33 additions & 0 deletions app/frontend/test/tidy.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,39 @@ describe('Tidy', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/v1/tidy/apply', expect.objectContaining({ method: 'POST' }));
expect(container.textContent).toContain('Tagged 1 item Finance. You can undo it from Tidy.');
});

it('adds a rule for a file type without words to look for', async () => {
const rule = { id: 'pdfs', spaceId: 'club', titleContains: '', kind: null, fileType: 'pdf', tag: finance, enabled: true, createdAt: '2026-10-05T10:00:00Z' };
const sent: unknown[] = [];
serve((path, init) => {
if (path === '/spaces/club') return json(club);
if (path === '/spaces/club/tags') return json([finance]);
if (path === '/spaces/club/tidy-rules' && init?.method === 'POST') {
sent.push(JSON.parse(String(init.body)));
return json(rule, 201);
}
if (path === '/spaces/club/tidy-rules') return json([]);
return undefined;
});
await render('/spaces/club/tags');

const form = [...container.querySelectorAll('form')].find((candidate) => candidate.textContent?.includes('New rule'))!;
await act(async () => {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
expect(container.textContent).toContain('Enter words to look for, or choose a file type.');
expect(sent).toEqual([]);

form.querySelector<HTMLSelectElement>('select[name="fileType"]')!.value = 'pdf';
form.querySelector<HTMLSelectElement>('select[name="kind"]')!.value = 'note';
await act(async () => {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
expect(sent).toEqual([{ titleContains: '', fileType: 'pdf', tagId: 'finance' }]);
expect(container.textContent).toContain('PDFs uploaded from now on will be tagged Finance.');
expect(container.textContent).toContain('PDFs → Finance');
});

it('finds duplicate files in a local folder without uploading them and deletes the chosen copy', async () => {
const fetchMock = serve(() => undefined);
await render('/tidy?view=folder');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "tidy_rules" ADD COLUMN "file_type" TEXT;

ALTER TABLE "tidy_rules" ALTER COLUMN "title_contains" SET DEFAULT '';
3 changes: 2 additions & 1 deletion app/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -628,8 +628,9 @@ model TidyRule {
id String @id @default(uuid()) @db.Uuid
spaceId String @map("space_id") @db.Uuid
createdById String @map("created_by") @db.Uuid
titleContains String @map("title_contains")
titleContains String @default("") @map("title_contains")
kind ItemKind?
fileType String? @map("file_type")
tagId String @map("tag_id") @db.Uuid
enabled Boolean @default(true)
checkedUntil DateTime @map("checked_until")
Expand Down
6 changes: 3 additions & 3 deletions app/server/src/files/file-inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ interface Image {

export const inlineTypes = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);

const officeTypes: Record<string, string> = {
export const officeTypes = {
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
odt: 'application/vnd.oasis.opendocument.text',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
odp: 'application/vnd.oasis.opendocument.presentation',
};
} as const;

const jpegFrameMarkers = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
const jpegMetadataMarkers = new Set([0xe1, 0xed, 0xfe]);
Expand Down Expand Up @@ -149,7 +149,7 @@ export function inspectFile(data: Buffer, name: string): InspectedFile | null {
}
if (data.toString('latin1', 0, 5) === '%PDF-') return attachment('application/pdf', data);
if (startsWith(data, 0x50, 0x4b, 0x03, 0x04) || startsWith(data, 0x50, 0x4b, 0x05, 0x06)) {
return attachment(officeTypes[name.split('.').pop()?.toLowerCase() ?? ''] ?? 'application/zip', data);
return attachment(officeTypes[(name.split('.').pop()?.toLowerCase() ?? '') as keyof typeof officeTypes] ?? 'application/zip', data);
}
const content = text(data);
if (content === null) return null;
Expand Down
10 changes: 8 additions & 2 deletions app/server/src/tidy/dto/tidy.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ArrayMaxSize, ArrayNotEmpty, ArrayUnique, IsArray, IsBoolean, IsIn, IsOptional, IsString, IsUUID, Length, ValidateIf } from 'class-validator';
import { itemKinds } from '../../items/dto/items.dto';
import { fileTypes } from '../file-types';
import { Trimmed } from '../../validation/trimmed.decorator';

export const tidyActions = ['archive', 'trash', 'tag', 'cancel'] as const;
Expand All @@ -22,10 +23,15 @@ export class TidyActionDto {
}

export class CreateTidyRuleDto {
@IsOptional()
@Trimmed()
@IsString()
@Length(1, 100, { message: 'Enter 1 to 100 characters to look for.' })
titleContains!: string;
@Length(0, 100, { message: 'Look for at most 100 characters.' })
titleContains?: string;

@IsOptional()
@IsIn(Object.keys(fileTypes), { message: 'Choose a file type from the list.' })
fileType?: string;

@IsOptional()
@IsIn(itemKinds, { message: 'That is not a kind of item.' })
Expand Down
14 changes: 14 additions & 0 deletions app/server/src/tidy/file-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
import { officeTypes } from '../files/file-inspection';

export const fileTypes = {
image: { startsWith: 'image/' },
pdf: { equals: 'application/pdf' },
document: { in: [officeTypes.docx, officeTypes.odt] },
spreadsheet: { in: [officeTypes.xlsx, officeTypes.ods] },
presentation: { in: [officeTypes.pptx, officeTypes.odp] },
archive: { equals: 'application/zip' },
text: { equals: 'text/plain' },
} satisfies Record<string, Prisma.StringFilter>;

export type FileType = keyof typeof fileTypes;
16 changes: 11 additions & 5 deletions app/server/src/tidy/tidy-rules.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MaintenanceScheduler } from '../jobs/maintenance.scheduler';
import { PrismaService } from '../prisma/prisma.service';
import { presentTag, tagFields } from '../tags/tags.service';
import { CreateTidyRuleDto } from './dto/tidy.dto';
import { fileTypes, type FileType } from './file-types';

const settleMs = 60_000;

Expand All @@ -13,6 +14,7 @@ const ruleFields = {
spaceId: true,
titleContains: true,
kind: true,
fileType: true,
enabled: true,
createdAt: true,
tag: { select: tagFields },
Expand All @@ -24,18 +26,20 @@ function presentRule(rule: Prisma.TidyRuleGetPayload<{ select: typeof ruleFields
spaceId: rule.spaceId,
titleContains: rule.titleContains,
kind: rule.kind?.toLowerCase() ?? null,
fileType: rule.fileType,
tag: presentTag(rule.tag),
enabled: rule.enabled,
createdAt: rule.createdAt,
};
}

function matching(rule: { spaceId: string; titleContains: string; kind: ItemKind | null; tagId: string }) {
function matching(rule: { spaceId: string; titleContains: string; kind: ItemKind | null; fileType: string | null; tagId: string }) {
return {
spaceId: rule.spaceId,
trashedAt: null,
kind: rule.kind ?? undefined,
title: { contains: rule.titleContains, mode: 'insensitive' },
title: rule.titleContains ? { contains: rule.titleContains, mode: 'insensitive' } : undefined,
file: rule.fileType ? { mimeType: fileTypes[rule.fileType as FileType] } : undefined,
tags: { none: { tagId: rule.tagId } },
} satisfies Prisma.ItemWhereInput;
}
Expand Down Expand Up @@ -64,14 +68,16 @@ export class TidyRulesService implements OnModuleInit {

async create(userId: string, spaceId: string, input: CreateTidyRuleDto) {
await this.access.assertSpace(userId, spaceId, 'edit');
if (!input.titleContains && !input.fileType) throw new BadRequestException('Look for words in the title, a file type, or both');
if (!(await this.prisma.tag.count({ where: { id: input.tagId, spaceId } }))) {
throw new BadRequestException("Choose a tag from this rule's space");
}
const rule = await this.prisma.tidyRule.create({
data: {
spaceId,
createdById: userId,
titleContains: input.titleContains,
titleContains: input.titleContains ?? '',
fileType: input.fileType ?? null,
kind: input.kind ? (input.kind.toUpperCase() as ItemKind) : null,
tagId: input.tagId,
checkedUntil: new Date(),
Expand Down Expand Up @@ -99,7 +105,7 @@ export class TidyRulesService implements OnModuleInit {
async matches(userId: string, ruleId: string) {
const rule = await this.prisma.tidyRule.findFirst({
where: { id: ruleId, space: this.access.spacesOf(userId) },
select: { spaceId: true, titleContains: true, kind: true, tagId: true },
select: { spaceId: true, titleContains: true, kind: true, fileType: true, tagId: true },
});
if (!rule) throw new NotFoundException('Rule not found');
const [total, items] = await this.prisma.$transaction([
Expand All @@ -118,7 +124,7 @@ export class TidyRulesService implements OnModuleInit {
const until = new Date(now.getTime() - settleMs);
const rules = await this.prisma.tidyRule.findMany({
where: { enabled: true, checkedUntil: { lt: until } },
select: { id: true, spaceId: true, createdById: true, titleContains: true, kind: true, tagId: true, checkedUntil: true },
select: { id: true, spaceId: true, createdById: true, titleContains: true, kind: true, fileType: true, tagId: true, checkedUntil: true },
take: 500,
});
for (const rule of rules) {
Expand Down
2 changes: 1 addition & 1 deletion app/server/src/tidy/tidy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const batchFields = {
createdAt: true,
undoneAt: true,
tag: { select: tagFields },
rule: { select: { id: true, titleContains: true } },
rule: { select: { id: true, titleContains: true, fileType: true } },
_count: { select: { changes: true } },
} satisfies Prisma.TidyBatchSelect;

Expand Down
23 changes: 23 additions & 0 deletions app/server/test/integration/tidy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,29 @@ describe('Tidy against Postgres', () => {
expect((await owner.call('GET', `/tidy-rules/${rule.id}/matches`)).status).toBe(404);
});

it('matches rules by file type, with or without words in the title', async () => {
const owner = await member('filer');
const papers = (await owner.call('POST', `/spaces/${owner.personalSpaceId}/tags`, { name: 'Papers' })).body;
const pdf = await file(owner, owner.personalSpaceId, 'lease.pdf', Buffer.from(`%PDF-1.4 lease ${owner.id}`));
await file(owner, owner.personalSpaceId, 'lease.txt', Buffer.from(`lease ${owner.id}`));
await strata.item(owner.personalSpaceId, 'lease notes');

expect((await owner.call('POST', `/spaces/${owner.personalSpaceId}/tidy-rules`, { tagId: papers.id })).status).toBe(400);
expect((await owner.call('POST', `/spaces/${owner.personalSpaceId}/tidy-rules`, { fileType: 'video', tagId: papers.id })).status).toBe(400);
const rule = (await owner.call('POST', `/spaces/${owner.personalSpaceId}/tidy-rules`, { fileType: 'pdf', tagId: papers.id })).body;
expect(rule).toMatchObject({ titleContains: '', fileType: 'pdf', kind: null });
expect(ids((await owner.call('GET', `/tidy-rules/${rule.id}/matches`)).body.results)).toEqual([pdf.id]);

const both = (await owner.call('POST', `/spaces/${owner.personalSpaceId}/tidy-rules`, { titleContains: 'invoice', fileType: 'pdf', tagId: papers.id })).body;
expect((await owner.call('GET', `/tidy-rules/${both.id}/matches`)).body.total).toBe(0);

const later = await file(owner, owner.personalSpaceId, 'invoice.pdf', Buffer.from(`%PDF-1.4 invoice ${owner.id}`));
await file(owner, owner.personalSpaceId, 'invoice.txt', Buffer.from(`invoice ${owner.id}`));
await strata.service(TidyRulesService).applyRules(new Date(Date.now() + 2 * 60_000));
const tagged = await strata.prisma.itemTag.findMany({ where: { tagId: papers.id }, select: { itemId: true } });
expect(tagged.map((entry) => entry.itemId)).toEqual([later.id]);
});

it('schedules a weekly summary for people who turned it on and sends it only when there is clutter', async () => {
const keen = await member('weekly');
const quiet = await member('quiet');
Expand Down
Loading