lots of changes for the new unified search

This commit is contained in:
jonaswinkler 2021-03-17 22:25:22 +01:00
parent 630cd814e2
commit b6ff88645b
29 changed files with 302 additions and 543 deletions

View File

@ -10,7 +10,6 @@ import { LogsComponent } from './components/manage/logs/logs.component';
import { SettingsComponent } from './components/manage/settings/settings.component'; import { SettingsComponent } from './components/manage/settings/settings.component';
import { TagListComponent } from './components/manage/tag-list/tag-list.component'; import { TagListComponent } from './components/manage/tag-list/tag-list.component';
import { NotFoundComponent } from './components/not-found/not-found.component'; import { NotFoundComponent } from './components/not-found/not-found.component';
import { SearchComponent } from './components/search/search.component';
const routes: Routes = [ const routes: Routes = [
{path: '', redirectTo: 'dashboard', pathMatch: 'full'}, {path: '', redirectTo: 'dashboard', pathMatch: 'full'},
@ -18,7 +17,6 @@ const routes: Routes = [
{path: 'dashboard', component: DashboardComponent }, {path: 'dashboard', component: DashboardComponent },
{path: 'documents', component: DocumentListComponent }, {path: 'documents', component: DocumentListComponent },
{path: 'view/:id', component: DocumentListComponent }, {path: 'view/:id', component: DocumentListComponent },
{path: 'search', component: SearchComponent },
{path: 'documents/:id', component: DocumentDetailComponent }, {path: 'documents/:id', component: DocumentDetailComponent },
{path: 'tags', component: TagListComponent }, {path: 'tags', component: TagListComponent },

View File

@ -21,8 +21,6 @@ import { CorrespondentEditDialogComponent } from './components/manage/correspond
import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component'; import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component'; import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
import { TagComponent } from './components/common/tag/tag.component'; import { TagComponent } from './components/common/tag/tag.component';
import { SearchComponent } from './components/search/search.component';
import { ResultHighlightComponent } from './components/search/result-highlight/result-highlight.component';
import { PageHeaderComponent } from './components/common/page-header/page-header.component'; import { PageHeaderComponent } from './components/common/page-header/page-header.component';
import { AppFrameComponent } from './components/app-frame/app-frame.component'; import { AppFrameComponent } from './components/app-frame/app-frame.component';
import { ToastsComponent } from './components/common/toasts/toasts.component'; import { ToastsComponent } from './components/common/toasts/toasts.component';
@ -104,8 +102,6 @@ registerLocaleData(localeEs)
TagEditDialogComponent, TagEditDialogComponent,
DocumentTypeEditDialogComponent, DocumentTypeEditDialogComponent,
TagComponent, TagComponent,
SearchComponent,
ResultHighlightComponent,
PageHeaderComponent, PageHeaderComponent,
AppFrameComponent, AppFrameComponent,
ToastsComponent, ToastsComponent,

View File

@ -10,6 +10,8 @@ import { SearchService } from 'src/app/services/rest/search.service';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { DocumentDetailComponent } from '../document-detail/document-detail.component'; import { DocumentDetailComponent } from '../document-detail/document-detail.component';
import { Meta } from '@angular/platform-browser'; import { Meta } from '@angular/platform-browser';
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type';
@Component({ @Component({
selector: 'app-app-frame', selector: 'app-app-frame',
@ -24,6 +26,7 @@ export class AppFrameComponent implements OnInit {
private openDocumentsService: OpenDocumentsService, private openDocumentsService: OpenDocumentsService,
private searchService: SearchService, private searchService: SearchService,
public savedViewService: SavedViewService, public savedViewService: SavedViewService,
private list: DocumentListViewService,
private meta: Meta private meta: Meta
) { ) {
@ -74,7 +77,7 @@ export class AppFrameComponent implements OnInit {
search() { search() {
this.closeMenu() this.closeMenu()
this.router.navigate(['search'], {queryParams: {query: this.searchField.value}}) this.list.quickFilter([{rule_type: FILTER_FULLTEXT_QUERY, value: this.searchField.value}])
} }
closeDocument(d: PaperlessDocument) { closeDocument(d: PaperlessDocument) {

View File

@ -20,6 +20,7 @@ import { ToastService } from 'src/app/services/toast.service';
import { TextComponent } from '../common/input/text/text.component'; import { TextComponent } from '../common/input/text/text.component';
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions'; import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions';
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type';
@Component({ @Component({
selector: 'app-document-detail', selector: 'app-document-detail',
@ -219,7 +220,7 @@ export class DocumentDetailComponent implements OnInit {
} }
moreLike() { moreLike() {
this.router.navigate(["search"], {queryParams: {more_like:this.document.id}}) this.documentListViewService.quickFilter([{rule_type: FILTER_FULLTEXT_MORELIKE, value: this.documentId.toString()}])
} }
hasNext() { hasNext() {

View File

@ -25,14 +25,14 @@
</h5> </h5>
</div> </div>
<p class="card-text"> <p class="card-text">
<app-result-highlight *ngIf="getDetailsAsHighlight()" class="result-content" [highlights]="getDetailsAsHighlight()"></app-result-highlight> <span *ngIf="document.__search_hit__" [innerHtml]="document.__search_hit__.highlights"></span>
<span *ngIf="getDetailsAsString()" class="result-content">{{getDetailsAsString()}}</span> <span *ngIf="!document.__search_hit__" class="result-content">{{contentTrimmed}}</span>
</p> </p>
<div class="d-flex flex-column flex-md-row align-items-md-center"> <div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="btn-group"> <div class="btn-group">
<a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary" *ngIf="moreLikeThis"> <a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/> <path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>More like this</span> </svg>&nbsp;<span class="d-block d-md-inline" i18n>More like this</span>
@ -62,9 +62,9 @@
</div> </div>
<div class="list-group list-group-horizontal border-0 card-info ml-md-auto mt-2 mt-md-0"> <div class="list-group list-group-horizontal border-0 card-info ml-md-auto mt-2 mt-md-0">
<div *ngIf="searchScore" class="list-group-item bg-light text-dark p-1 mr-5 border-0 d-flex search-score"> <div *ngIf="document.__search_hit__" class="list-group-item bg-light text-dark p-1 mr-5 border-0 d-flex search-score">
<small class="text-muted" i18n>Score:</small> <small class="text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar> <ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div> </div>
<button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 mr-2" title="Filter by document type" <button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 mr-2" title="Filter by document type"
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">

View File

@ -60,3 +60,8 @@
padding-top: 0.35rem !important; padding-top: 0.35rem !important;
} }
} }
span ::ng-deep .match {
color: black;
background-color: rgb(255, 211, 66);
}

View File

@ -4,6 +4,8 @@ import { PaperlessDocument } from 'src/app/data/paperless-document';
import { DocumentService } from 'src/app/services/rest/document.service'; import { DocumentService } from 'src/app/services/rest/document.service';
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'; import { NgbPopover } from '@ng-bootstrap/ng-bootstrap';
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type';
@Component({ @Component({
selector: 'app-document-card-large', selector: 'app-document-card-large',
@ -24,15 +26,9 @@ export class DocumentCardLargeComponent implements OnInit {
return this.toggleSelected.observers.length > 0 return this.toggleSelected.observers.length > 0
} }
@Input()
moreLikeThis: boolean = false
@Input() @Input()
document: PaperlessDocument document: PaperlessDocument
@Input()
details: any
@Output() @Output()
clickTag = new EventEmitter<number>() clickTag = new EventEmitter<number>()
@ -42,6 +38,9 @@ export class DocumentCardLargeComponent implements OnInit {
@Output() @Output()
clickDocumentType = new EventEmitter<number>() clickDocumentType = new EventEmitter<number>()
@Output()
clickMoreLike= new EventEmitter()
@Input() @Input()
searchScore: number searchScore: number
@ -67,19 +66,6 @@ export class DocumentCardLargeComponent implements OnInit {
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
} }
getDetailsAsString() {
if (typeof this.details === 'string') {
return this.details.substring(0, 500)
}
}
getDetailsAsHighlight() {
//TODO: this is not an exact typecheck, can we do better
if (this.details instanceof Array) {
return this.details
}
}
getThumbUrl() { getThumbUrl() {
return this.documentService.getThumbUrl(this.document.id) return this.documentService.getThumbUrl(this.document.id)
} }
@ -116,4 +102,8 @@ export class DocumentCardLargeComponent implements OnInit {
mouseLeaveCard() { mouseLeaveCard() {
this.popover.close() this.popover.close()
} }
get contentTrimmed() {
return this.document.content.substr(0, 500)
}
} }

View File

@ -90,7 +90,7 @@
</div> </div>
<div *ngIf="displayMode == 'largeCards'"> <div *ngIf="displayMode == 'largeCards'">
<app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"> <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickMoreLike)="clickMoreLike(d.id)">
</app-document-card-large> </app-document-card-large>
</div> </div>

View File

@ -207,6 +207,13 @@ export class DocumentListComponent implements OnInit, OnDestroy {
}) })
} }
clickMoreLike(documentID: number) {
this.list.selectNone()
setTimeout(() => {
//this.filterEditor.moreLikeThis(doc)
})
}
trackByDocumentId(index, item: PaperlessDocument) { trackByDocumentId(index, item: PaperlessDocument) {
return item.id return item.id
} }

View File

@ -1,7 +1,6 @@
<div class="row"> <div class="row">
<div class="col mb-2 mb-xl-0"> <div class="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<label class="text-muted mr-2 mb-0" i18n>Filter by:</label>
<div class="input-group input-group-sm flex-fill w-auto"> <div class="input-group input-group-sm flex-fill w-auto">
<div class="input-group-prepend" ngbDropdown> <div class="input-group-prepend" ngbDropdown>
<button class="btn btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> <button class="btn btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button>
@ -9,7 +8,8 @@
<button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button> <button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button>
</div> </div>
</div> </div>
<input class="form-control form-control-sm" type="text" [(ngModel)]="textFilter"> <input class="form-control form-control-sm" type="text" [(ngModel)]="textFilter" *ngIf="textFilterTarget != 'fulltext-morelike'">
<span class="form-control form-control-sm text-truncate" *ngIf="textFilterTarget == 'fulltext-morelike'">{{_moreLikeDoc?.title}}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,13 +8,17 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
import { TagService } from 'src/app/services/rest/tag.service'; import { TagService } from 'src/app/services/rest/tag.service';
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
import { FilterRule } from 'src/app/data/filter-rule'; import { FilterRule } from 'src/app/data/filter-rule';
import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_ASN, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE, FILTER_TITLE_CONTENT } from 'src/app/data/filter-rule-type'; import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_ASN, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE, FILTER_TITLE_CONTENT } from 'src/app/data/filter-rule-type';
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component'; import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component';
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
import { DocumentService } from 'src/app/services/rest/document.service';
import { PaperlessDocument } from 'src/app/data/paperless-document';
const TEXT_FILTER_TARGET_TITLE = "title" const TEXT_FILTER_TARGET_TITLE = "title"
const TEXT_FILTER_TARGET_TITLE_CONTENT = "title-content" const TEXT_FILTER_TARGET_TITLE_CONTENT = "title-content"
const TEXT_FILTER_TARGET_ASN = "asn" const TEXT_FILTER_TARGET_ASN = "asn"
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = "fulltext-query"
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = "fulltext-morelike"
@Component({ @Component({
selector: 'app-filter-editor', selector: 'app-filter-editor',
@ -64,7 +68,8 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
constructor( constructor(
private documentTypeService: DocumentTypeService, private documentTypeService: DocumentTypeService,
private tagService: TagService, private tagService: TagService,
private correspondentService: CorrespondentService private correspondentService: CorrespondentService,
private documentService: DocumentService
) { } ) { }
tags: PaperlessTag[] = [] tags: PaperlessTag[] = []
@ -72,12 +77,21 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
documentTypes: PaperlessDocumentType[] = [] documentTypes: PaperlessDocumentType[] = []
_textFilter = "" _textFilter = ""
_moreLikeId: number
_moreLikeDoc: PaperlessDocument
textFilterTargets = [ get textFilterTargets() {
{id: TEXT_FILTER_TARGET_TITLE, name: $localize`Title`}, let targets = [
{id: TEXT_FILTER_TARGET_TITLE_CONTENT, name: $localize`Title & content`}, {id: TEXT_FILTER_TARGET_TITLE, name: $localize`Title`},
{id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN`} {id: TEXT_FILTER_TARGET_TITLE_CONTENT, name: $localize`Title & content`},
] {id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN`},
{id: TEXT_FILTER_TARGET_FULLTEXT_QUERY, name: $localize`Fulltext search`}
]
if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
targets.push({id: TEXT_FILTER_TARGET_FULLTEXT_MORELIKE, name: $localize`More like`})
}
return targets
}
textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
@ -101,6 +115,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this.tagSelectionModel.clear(false) this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false) this.correspondentSelectionModel.clear(false)
this._textFilter = null this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null this.dateAddedBefore = null
this.dateAddedAfter = null this.dateAddedAfter = null
this.dateCreatedBefore = null this.dateCreatedBefore = null
@ -120,6 +135,17 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this._textFilter = rule.value this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break break
case FILTER_FULLTEXT_QUERY:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_QUERY
break
case FILTER_FULLTEXT_MORELIKE:
this._moreLikeId = +rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
this.documentService.get(this._moreLikeId).subscribe(result => {
this._moreLikeDoc = result
})
break
case FILTER_CREATED_AFTER: case FILTER_CREATED_AFTER:
this.dateCreatedAfter = rule.value this.dateCreatedAfter = rule.value
break break
@ -159,6 +185,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_ASN) { if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_ASN) {
filterRules.push({rule_type: FILTER_ASN, value: this._textFilter}) filterRules.push({rule_type: FILTER_ASN, value: this._textFilter})
} }
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY) {
filterRules.push({rule_type: FILTER_FULLTEXT_QUERY, value: this._textFilter})
}
if (this._moreLikeId && this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
filterRules.push({rule_type: FILTER_FULLTEXT_MORELIKE, value: this._moreLikeId?.toString()})
}
if (this.tagSelectionModel.isNoneSelected()) { if (this.tagSelectionModel.isNoneSelected()) {
filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"}) filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"})
} else { } else {
@ -232,6 +264,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
} }
resetSelected() { resetSelected() {
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
this.reset.next() this.reset.next()
} }

View File

@ -1,3 +0,0 @@
... <span *ngFor="let fragment of highlights">
<span *ngFor="let token of fragment" [class.match]="token.highlight">{{token.text}}</span> ...
</span>

View File

@ -1,4 +0,0 @@
.match {
color: black;
background-color: rgb(255, 211, 66);
}

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResultHighlightComponent } from './result-highlight.component';
describe('ResultHighlightComponent', () => {
let component: ResultHighlightComponent;
let fixture: ComponentFixture<ResultHighlightComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ResultHighlightComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ResultHighlightComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,19 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { SearchHitHighlight } from 'src/app/data/search-result';
@Component({
selector: 'app-result-highlight',
templateUrl: './result-highlight.component.html',
styleUrls: ['./result-highlight.component.scss']
})
export class ResultHighlightComponent implements OnInit {
constructor() { }
@Input()
highlights: SearchHitHighlight[][]
ngOnInit(): void {
}
}

View File

@ -1,26 +0,0 @@
<app-page-header i18n-title title="Search results">
</app-page-header>
<div *ngIf="errorMessage" class="alert alert-danger" i18n>Invalid search query: {{errorMessage}}</div>
<p *ngIf="more_like" i18n>Showing documents similar to <a routerLink="/documents/{{more_like}}">{{more_like_doc?.original_file_name}}</a></p>
<p *ngIf="query">
<ng-container i18n>Search query: <i>{{query}}</i></ng-container>
<ng-container *ngIf="correctedQuery">
- <ng-container i18n>Did you mean "<a [routerLink]="" (click)="searchCorrectedQuery()">{{correctedQuery}}</a>"?</ng-container>
</ng-container>
</p>
<div *ngIf="!errorMessage" [class.result-content-searching]="searching" infiniteScroll (scrolled)="onScroll()">
<p i18n>{resultCount, plural, =0 {No results} =1 {One result} other {{{resultCount}} results}}</p>
<ng-container *ngFor="let result of results">
<app-document-card-large *ngIf="result.document"
[document]="result.document"
[details]="result.highlights"
[searchScore]="result.score / maxScore"
[moreLikeThis]="true">
</app-document-card-large>
</ng-container>
</div>

View File

@ -1,15 +0,0 @@
.result-content {
color: darkgray;
}
.doc-img {
object-fit: cover;
object-position: top;
height: 100%;
position: absolute;
}
.result-content-searching {
opacity: 0.3;
}

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ SearchComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,95 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { PaperlessDocument } from 'src/app/data/paperless-document';
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
import { SearchHit } from 'src/app/data/search-result';
import { DocumentService } from 'src/app/services/rest/document.service';
import { SearchService } from 'src/app/services/rest/search.service';
@Component({
selector: 'app-search',
templateUrl: './search.component.html',
styleUrls: ['./search.component.scss']
})
export class SearchComponent implements OnInit {
results: SearchHit[] = []
query: string = ""
more_like: number
more_like_doc: PaperlessDocument
searching = false
currentPage = 1
pageCount = 1
resultCount
correctedQuery: string = null
errorMessage: string
get maxScore() {
return this.results?.length > 0 ? this.results[0].score : 100
}
constructor(private searchService: SearchService, private route: ActivatedRoute, private router: Router, private documentService: DocumentService) { }
ngOnInit(): void {
this.route.queryParamMap.subscribe(paramMap => {
window.scrollTo(0, 0)
this.query = paramMap.get('query')
this.more_like = paramMap.has('more_like') ? +paramMap.get('more_like') : null
if (this.more_like) {
this.documentService.get(this.more_like).subscribe(r => {
this.more_like_doc = r
})
} else {
this.more_like_doc = null
}
this.searching = true
this.currentPage = 1
this.loadPage()
})
}
searchCorrectedQuery() {
this.router.navigate(["search"], {queryParams: {query: this.correctedQuery, more_like: this.more_like}})
}
loadPage(append: boolean = false) {
this.errorMessage = null
this.correctedQuery = null
this.searchService.search(this.query, this.currentPage, this.more_like).subscribe(result => {
if (append) {
this.results.push(...result.results)
} else {
this.results = result.results
}
this.pageCount = result.page_count
this.searching = false
this.resultCount = result.count
this.correctedQuery = result.corrected_query
}, error => {
this.searching = false
this.resultCount = 1
this.pageCount = 1
this.results = []
this.errorMessage = error.error
})
}
onScroll() {
if (this.currentPage < this.pageCount) {
this.currentPage += 1
this.loadPage(true)
}
}
}

View File

@ -22,6 +22,9 @@ export const FILTER_ASN_ISNULL = 18
export const FILTER_TITLE_CONTENT = 19 export const FILTER_TITLE_CONTENT = 19
export const FILTER_FULLTEXT_QUERY = 20
export const FILTER_FULLTEXT_MORELIKE = 21
export const FILTER_RULE_TYPES: FilterRuleType[] = [ export const FILTER_RULE_TYPES: FilterRuleType[] = [
{id: FILTER_TITLE, filtervar: "title__icontains", datatype: "string", multi: false, default: ""}, {id: FILTER_TITLE, filtervar: "title__icontains", datatype: "string", multi: false, default: ""},
@ -51,7 +54,11 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
{id: FILTER_MODIFIED_AFTER, filtervar: "modified__date__gt", datatype: "date", multi: false}, {id: FILTER_MODIFIED_AFTER, filtervar: "modified__date__gt", datatype: "date", multi: false},
{id: FILTER_ASN_ISNULL, filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false}, {id: FILTER_ASN_ISNULL, filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false},
{id: FILTER_TITLE_CONTENT, filtervar: "title_content", datatype: "string", multi: false} {id: FILTER_TITLE_CONTENT, filtervar: "title_content", datatype: "string", multi: false},
{id: FILTER_FULLTEXT_QUERY, filtervar: "query", datatype: "string", multi: false},
{id: FILTER_FULLTEXT_MORELIKE, filtervar: "more_like_id", datatype: "number", multi: false},
] ]
export interface FilterRuleType { export interface FilterRuleType {

View File

@ -4,6 +4,15 @@ import { PaperlessTag } from './paperless-tag'
import { PaperlessDocumentType } from './paperless-document-type' import { PaperlessDocumentType } from './paperless-document-type'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
export interface SearchHit {
score?: number
rank?: number
highlights?: string
}
export interface PaperlessDocument extends ObjectWithId { export interface PaperlessDocument extends ObjectWithId {
correspondent$?: Observable<PaperlessCorrespondent> correspondent$?: Observable<PaperlessCorrespondent>
@ -40,4 +49,6 @@ export interface PaperlessDocument extends ObjectWithId {
archive_serial_number?: number archive_serial_number?: number
__search_hit__?: SearchHit
} }

View File

@ -1,29 +0,0 @@
import { PaperlessDocument } from './paperless-document'
export class SearchHitHighlight {
text?: string
term?: number
}
export interface SearchHit {
id?: number
title?: string
score?: number
rank?: number
highlights?: SearchHitHighlight[][]
document?: PaperlessDocument
}
export interface SearchResult {
count?: number
page?: number
page_count?: number
corrected_query?: string
results?: SearchHit[]
}

View File

@ -1,7 +1,9 @@
import { Route } from '@angular/compiler/src/core';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { cloneFilterRules, FilterRule } from '../data/filter-rule'; import { cloneFilterRules, FilterRule } from '../data/filter-rule';
import { FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY } from '../data/filter-rule-type';
import { PaperlessDocument } from '../data/paperless-document'; import { PaperlessDocument } from '../data/paperless-document';
import { PaperlessSavedView } from '../data/paperless-saved-view'; import { PaperlessSavedView } from '../data/paperless-saved-view';
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'; import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys';
@ -207,7 +209,11 @@ export class DocumentListViewService {
this.activeListViewState.currentPage = 1 this.activeListViewState.currentPage = 1
this.reduceSelectionToFilter() this.reduceSelectionToFilter()
this.saveDocumentListView() this.saveDocumentListView()
this.router.navigate(["documents"]) if (this.router.url == "/documents") {
this.reload()
} else {
this.router.navigate(["documents"])
}
} }
getLastPage(): number { getLastPage(): number {
@ -317,7 +323,7 @@ export class DocumentListViewService {
return this.documents.map(d => d.id).indexOf(documentID) return this.documents.map(d => d.id).indexOf(documentID)
} }
constructor(private documentService: DocumentService, private settings: SettingsService, private router: Router) { constructor(private documentService: DocumentService, private settings: SettingsService, private router: Router, private route: ActivatedRoute) {
let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
if (documentListViewConfigJson) { if (documentListViewConfigJson) {
try { try {

View File

@ -2,8 +2,6 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { PaperlessDocument } from 'src/app/data/paperless-document';
import { SearchResult } from 'src/app/data/search-result';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { DocumentService } from './document.service'; import { DocumentService } from './document.service';
@ -13,30 +11,7 @@ import { DocumentService } from './document.service';
}) })
export class SearchService { export class SearchService {
constructor(private http: HttpClient, private documentService: DocumentService) { } constructor(private http: HttpClient) { }
search(query: string, page?: number, more_like?: number): Observable<SearchResult> {
let httpParams = new HttpParams()
if (query) {
httpParams = httpParams.set('query', query)
}
if (page) {
httpParams = httpParams.set('page', page.toString())
}
if (more_like) {
httpParams = httpParams.set('more_like', more_like.toString())
}
return this.http.get<SearchResult>(`${environment.apiBaseUrl}search/`, {params: httpParams}).pipe(
map(result => {
result.results.forEach(hit => {
if (hit.document) {
this.documentService.addObservablesToDocument(hit.document)
}
})
return result
})
)
}
autocomplete(term: string): Observable<string[]> { autocomplete(term: string): Observable<string[]> {
return this.http.get<string[]>(`${environment.apiBaseUrl}search/autocomplete/`, {params: new HttpParams().set('term', term)}) return this.http.get<string[]>(`${environment.apiBaseUrl}search/autocomplete/`, {params: new HttpParams().set('term', term)})

View File

@ -5,12 +5,12 @@ from contextlib import contextmanager
import math import math
from django.conf import settings from django.conf import settings
from whoosh import highlight, classify, query from whoosh import highlight, classify, query
from whoosh.fields import Schema, TEXT, NUMERIC, KEYWORD, DATETIME from whoosh.fields import Schema, TEXT, NUMERIC, KEYWORD, DATETIME, BOOLEAN
from whoosh.highlight import Formatter, get_text from whoosh.highlight import Formatter, get_text, HtmlFormatter
from whoosh.index import create_in, exists_in, open_dir from whoosh.index import create_in, exists_in, open_dir
from whoosh.qparser import MultifieldParser from whoosh.qparser import MultifieldParser
from whoosh.qparser.dateparse import DateParserPlugin from whoosh.qparser.dateparse import DateParserPlugin
from whoosh.searching import ResultsPage from whoosh.searching import ResultsPage, Searcher
from whoosh.writing import AsyncWriter from whoosh.writing import AsyncWriter
from documents.models import Document from documents.models import Document
@ -18,63 +18,53 @@ from documents.models import Document
logger = logging.getLogger("paperless.index") logger = logging.getLogger("paperless.index")
class JsonFormatter(Formatter):
def __init__(self):
self.seen = {}
def format_token(self, text, token, replace=False):
ttext = self._text(get_text(text, token, replace))
return {'text': ttext, 'highlight': 'true'}
def format_fragment(self, fragment, replace=False):
output = []
index = fragment.startchar
text = fragment.text
amend_token = None
for t in fragment.matches:
if t.startchar is None:
continue
if t.startchar < index:
continue
if t.startchar > index:
text_inbetween = text[index:t.startchar]
if amend_token and t.startchar - index < 10:
amend_token['text'] += text_inbetween
else:
output.append({'text': text_inbetween,
'highlight': False})
amend_token = None
token = self.format_token(text, t, replace)
if amend_token:
amend_token['text'] += token['text']
else:
output.append(token)
amend_token = token
index = t.endchar
if index < fragment.endchar:
output.append({'text': text[index:fragment.endchar],
'highlight': False})
return output
def format(self, fragments, replace=False):
output = []
for fragment in fragments:
output.append(self.format_fragment(fragment, replace=replace))
return output
def get_schema(): def get_schema():
return Schema( return Schema(
id=NUMERIC(stored=True, unique=True, numtype=int), id=NUMERIC(
title=TEXT(stored=True), stored=True,
unique=True
),
title=TEXT(
sortable=True
),
content=TEXT(), content=TEXT(),
correspondent=TEXT(stored=True), archive_serial_number=NUMERIC(
correspondent_id=NUMERIC(stored=True, numtype=int), sortable=True
tag=KEYWORD(stored=True, commas=True, scorable=True, lowercase=True), ),
type=TEXT(stored=True),
created=DATETIME(stored=True, sortable=True), correspondent=TEXT(
modified=DATETIME(stored=True, sortable=True), sortable=True
added=DATETIME(stored=True, sortable=True), ),
correspondent_id=NUMERIC(),
has_correspondent=BOOLEAN(),
tag=KEYWORD(
commas=True,
scorable=True,
lowercase=True
),
tag_id=KEYWORD(
commas=True,
scorable=True
),
has_tag=BOOLEAN(),
type=TEXT(
sortable=True
),
type_id=NUMERIC(),
has_type=BOOLEAN(),
created=DATETIME(
sortable=True
),
modified=DATETIME(
sortable=True
),
added=DATETIME(
sortable=True
),
) )
@ -106,18 +96,38 @@ def open_index_writer(ix=None, optimize=False):
writer.commit(optimize=optimize) writer.commit(optimize=optimize)
@contextmanager
def open_index_searcher(ix=None):
if ix:
searcher = ix.searcher()
else:
searcher = open_index().searcher()
try:
yield searcher
finally:
searcher.close()
def update_document(writer, doc): def update_document(writer, doc):
tags = ",".join([t.name for t in doc.tags.all()]) tags = ",".join([t.name for t in doc.tags.all()])
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
writer.update_document( writer.update_document(
id=doc.pk, id=doc.pk,
title=doc.title, title=doc.title,
content=doc.content, content=doc.content,
correspondent=doc.correspondent.name if doc.correspondent else None, correspondent=doc.correspondent.name if doc.correspondent else None,
correspondent_id=doc.correspondent.id if doc.correspondent else None, correspondent_id=doc.correspondent.id if doc.correspondent else None,
has_correspondent=doc.correspondent is not None,
tag=tags if tags else None, tag=tags if tags else None,
tag_id=tags_ids if tags_ids else None,
has_tag=len(tags) > 0,
type=doc.document_type.name if doc.document_type else None, type=doc.document_type.name if doc.document_type else None,
type_id=doc.document_type.id if doc.document_type else None,
has_type=doc.document_type is not None,
created=doc.created, created=doc.created,
added=doc.added, added=doc.added,
archive_serial_number=doc.archive_serial_number,
modified=doc.modified, modified=doc.modified,
) )
@ -140,78 +150,11 @@ def remove_document_from_index(document):
remove_document(writer, document) remove_document(writer, document)
@contextmanager
def query_page(ix, page, querystring, more_like_doc_id, more_like_doc_content):
searcher = ix.searcher()
try:
if querystring:
qp = MultifieldParser(
["content", "title", "correspondent", "tag", "type"],
ix.schema)
qp.add_plugin(DateParserPlugin())
str_q = qp.parse(querystring)
corrected = searcher.correct_query(str_q, querystring)
else:
str_q = None
corrected = None
if more_like_doc_id:
docnum = searcher.document_number(id=more_like_doc_id)
kts = searcher.key_terms_from_text(
'content', more_like_doc_content, numterms=20,
model=classify.Bo1Model, normalize=False)
more_like_q = query.Or(
[query.Term('content', word, boost=weight)
for word, weight in kts])
result_page = searcher.search_page(
more_like_q, page, filter=str_q, mask={docnum})
elif str_q:
result_page = searcher.search_page(str_q, page)
else:
raise ValueError(
"Either querystring or more_like_doc_id is required."
)
result_page.results.fragmenter = highlight.ContextFragmenter(
surround=50)
result_page.results.formatter = JsonFormatter()
if corrected and corrected.query != str_q:
corrected_query = corrected.string
else:
corrected_query = None
yield result_page, corrected_query
finally:
searcher.close()
class DelayedQuery: class DelayedQuery:
@property @property
def _query(self): def _query(self):
if 'query' in self.query_params: raise NotImplementedError()
qp = MultifieldParser(
["content", "title", "correspondent", "tag", "type"],
self.ix.schema)
qp.add_plugin(DateParserPlugin())
q = qp.parse(self.query_params['query'])
elif 'more_like_id' in self.query_params:
more_like_doc_id = int(self.query_params['more_like_id'])
content = Document.objects.get(id=more_like_doc_id).content
docnum = self.searcher.document_number(id=more_like_doc_id)
kts = self.searcher.key_terms_from_text(
'content', content, numterms=20,
model=classify.Bo1Model, normalize=False)
q = query.Or(
[query.Term('content', word, boost=weight)
for word, weight in kts])
else:
raise ValueError(
"Either query or more_like_id is required."
)
return q
@property @property
def _query_filter(self): def _query_filter(self):
@ -219,32 +162,114 @@ class DelayedQuery:
for k, v in self.query_params.items(): for k, v in self.query_params.items():
if k == 'correspondent__id': if k == 'correspondent__id':
criterias.append(query.Term('correspondent_id', v)) criterias.append(query.Term('correspondent_id', v))
elif k == 'tags__id__all':
for tag_id in v.split(","):
criterias.append(query.Term('tag_id', tag_id))
elif k == 'document_type__id':
criterias.append(query.Term('type_id', v))
elif k == 'correspondent__isnull':
criterias.append(query.Term("has_correspondent", v == "false"))
elif k == 'is_tagged':
criterias.append(query.Term("has_tag", v == "true"))
elif k == 'document_type__isnull':
criterias.append(query.Term("has_type", v == "false"))
elif k == 'created__date__lt':
pass
elif k == 'created__date__gt':
pass
elif k == 'added__date__gt':
pass
elif k == 'added__date__lt':
pass
if len(criterias) > 0: if len(criterias) > 0:
return query.And(criterias) return query.And(criterias)
else: else:
return None return None
def __init__(self, ix, searcher, query_params, page_size): @property
self.ix = ix def _query_sortedby(self):
if not 'ordering' in self.query_params:
return None, False
o: str = self.query_params['ordering']
if o.startswith('-'):
return o[1:], True
else:
return o, False
def __init__(self, searcher: Searcher, query_params, page_size):
self.searcher = searcher self.searcher = searcher
self.query_params = query_params self.query_params = query_params
self.page_size = page_size self.page_size = page_size
self.saved_results = dict()
def __len__(self): def __len__(self):
results = self.searcher.search(self._query, limit=1, filter=self._query_filter) page = self[0:1]
return len(results) return len(page)
#return 1000
def __getitem__(self, item): def __getitem__(self, item):
if item.start in self.saved_results:
return self.saved_results[item.start]
q, mask = self._query
sortedby, reverse = self._query_sortedby
print("OY", self.page_size)
page: ResultsPage = self.searcher.search_page( page: ResultsPage = self.searcher.search_page(
self._query, q,
mask=mask,
filter=self._query_filter, filter=self._query_filter,
pagenum=math.floor(item.start / self.page_size) + 1, pagenum=math.floor(item.start / self.page_size) + 1,
pagelen=self.page_size pagelen=self.page_size,
sortedby=sortedby,
reverse=reverse
) )
page.results.fragmenter = highlight.ContextFragmenter(
surround=50)
page.results.formatter = HtmlFormatter(tagname="span", between=" ... ")
self.saved_results[item.start] = page
return page return page
class DelayedFullTextQuery(DelayedQuery):
@property
def _query(self):
q_str = self.query_params['query']
qp = MultifieldParser(
["content", "title", "correspondent", "tag", "type"],
self.searcher.ixreader.schema)
qp.add_plugin(DateParserPlugin())
q = qp.parse(q_str)
corrected = self.searcher.correct_query(q, q_str)
if corrected.query != q:
corrected_query = corrected.string
return q, None
class DelayedMoreLikeThisQuery(DelayedQuery):
@property
def _query(self):
more_like_doc_id = int(self.query_params['more_like_id'])
content = Document.objects.get(id=more_like_doc_id).content
docnum = self.searcher.document_number(id=more_like_doc_id)
kts = self.searcher.key_terms_from_text(
'content', content, numterms=20,
model=classify.Bo1Model, normalize=False)
q = query.Or(
[query.Term('content', word, boost=weight)
for word, weight in kts])
mask = {docnum}
return q, mask
def autocomplete(ix, term, limit=10): def autocomplete(ix, term, limit=10):
with ix.reader() as reader: with ix.reader() as reader:
terms = [] terms = []

View File

@ -359,7 +359,10 @@ class SavedView(models.Model):
sort_field = models.CharField( sort_field = models.CharField(
_("sort field"), _("sort field"),
max_length=128) max_length=128,
null=True,
blank=True
)
sort_reverse = models.BooleanField( sort_reverse = models.BooleanField(
_("sort reverse"), _("sort reverse"),
default=False) default=False)
@ -387,6 +390,8 @@ class SavedViewFilterRule(models.Model):
(17, _("does not have tag")), (17, _("does not have tag")),
(18, _("does not have ASN")), (18, _("does not have ASN")),
(19, _("title or content contains")), (19, _("title or content contains")),
(20, _("fulltext query")),
(21, _("more like this"))
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(

View File

@ -1,20 +1,10 @@
from django.test import TestCase from django.test import TestCase
from documents import index from documents import index
from documents.index import JsonFormatter
from documents.models import Document from documents.models import Document
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
class JsonFormatterTest(TestCase):
def setUp(self) -> None:
self.formatter = JsonFormatter()
def test_empty_fragments(self):
self.assertListEqual(self.formatter.format([]), [])
class TestAutoComplete(DirectoriesMixin, TestCase): class TestAutoComplete(DirectoriesMixin, TestCase):
def test_auto_complete(self): def test_auto_complete(self):

View File

@ -36,7 +36,6 @@ from rest_framework.viewsets import (
from paperless.db import GnuPG from paperless.db import GnuPG
from paperless.views import StandardPagination from paperless.views import StandardPagination
from . import index
from .bulk_download import OriginalAndArchiveStrategy, OriginalsOnlyStrategy, \ from .bulk_download import OriginalAndArchiveStrategy, OriginalsOnlyStrategy, \
ArchiveOnlyStrategy ArchiveOnlyStrategy
from .classifier import load_classifier from .classifier import load_classifier
@ -332,15 +331,23 @@ class SearchResultSerializer(DocumentSerializer):
def to_representation(self, instance): def to_representation(self, instance):
doc = Document.objects.get(id=instance['id']) doc = Document.objects.get(id=instance['id'])
# repressentation = super(SearchResultSerializer, self).to_representation(doc) representation = super(SearchResultSerializer, self).to_representation(doc)
# repressentation['__search_hit__'] = { representation['__search_hit__'] = {
# "score": instance.score "score": instance.score,
# } "highlights": instance.highlights("content",
return super(SearchResultSerializer, self).to_representation(doc) text=doc.content) if doc else None, # NOQA: E501
"rank": instance.rank
}
return representation
class UnifiedSearchViewSet(DocumentViewSet): class UnifiedSearchViewSet(DocumentViewSet):
def __init__(self, *args, **kwargs):
super(UnifiedSearchViewSet, self).__init__(*args, **kwargs)
self.searcher = None
def get_serializer_class(self): def get_serializer_class(self):
if self._is_search_request(): if self._is_search_request():
return SearchResultSerializer return SearchResultSerializer
@ -348,25 +355,39 @@ class UnifiedSearchViewSet(DocumentViewSet):
return DocumentSerializer return DocumentSerializer
def _is_search_request(self): def _is_search_request(self):
return "query" in self.request.query_params return "query" in self.request.query_params or "more_like_id" in self.request.query_params
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
if self._is_search_request(): if self._is_search_request():
ix = index.open_index() from documents import index
return index.DelayedQuery(ix, self.searcher, self.request.query_params, self.paginator.page_size)
if "query" in self.request.query_params:
query_class = index.DelayedFullTextQuery
elif "more_like_id" in self.request.query_params:
query_class = index.DelayedMoreLikeThisQuery
else:
raise ValueError()
return query_class(
self.searcher,
self.request.query_params,
self.paginator.get_page_size(self.request))
else: else:
return super(UnifiedSearchViewSet, self).filter_queryset(queryset) return super(UnifiedSearchViewSet, self).filter_queryset(queryset)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
if self._is_search_request(): if self._is_search_request():
ix = index.open_index() from documents import index
with ix.searcher() as s: try:
self.searcher = s with index.open_index_searcher() as s:
return super(UnifiedSearchViewSet, self).list(request) self.searcher = s
return super(UnifiedSearchViewSet, self).list(request)
except Exception as e:
return HttpResponseBadRequest(str(e))
else: else:
return super(UnifiedSearchViewSet, self).list(request) return super(UnifiedSearchViewSet, self).list(request)
class LogViewSet(ViewSet): class LogViewSet(ViewSet):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
@ -518,74 +539,6 @@ class SelectionDataView(GenericAPIView):
return r return r
class SearchView(APIView):
permission_classes = (IsAuthenticated,)
def add_infos_to_hit(self, r):
try:
doc = Document.objects.get(id=r['id'])
except Document.DoesNotExist:
logger.warning(
f"Search index returned a non-existing document: "
f"id: {r['id']}, title: {r['title']}. "
f"Search index needs reindex."
)
doc = None
return {'id': r['id'],
'highlights': r.highlights("content", text=doc.content) if doc else None, # NOQA: E501
'score': r.score,
'rank': r.rank,
'document': DocumentSerializer(doc).data if doc else None,
'title': r['title']
}
def get(self, request, format=None):
from documents import index
if 'query' in request.query_params:
query = request.query_params['query']
else:
query = None
if 'more_like' in request.query_params:
more_like_id = request.query_params['more_like']
more_like_content = Document.objects.get(id=more_like_id).content
else:
more_like_id = None
more_like_content = None
if not query and not more_like_id:
return Response({
'count': 0,
'page': 0,
'page_count': 0,
'corrected_query': None,
'results': []})
try:
page = int(request.query_params.get('page', 1))
except (ValueError, TypeError):
page = 1
if page < 1:
page = 1
ix = index.open_index()
try:
with index.query_page(ix, page, query, more_like_id, more_like_content) as (result_page, corrected_query): # NOQA: E501
return Response(
{'count': len(result_page),
'page': result_page.pagenum,
'page_count': result_page.pagecount,
'corrected_query': corrected_query,
'results': list(map(self.add_infos_to_hit, result_page))})
except Exception as e:
return HttpResponseBadRequest(str(e))
class SearchAutoCompleteView(APIView): class SearchAutoCompleteView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)

View File

@ -16,7 +16,6 @@ from documents.views import (
LogViewSet, LogViewSet,
TagViewSet, TagViewSet,
DocumentTypeViewSet, DocumentTypeViewSet,
SearchView,
IndexView, IndexView,
SearchAutoCompleteView, SearchAutoCompleteView,
StatisticsView, StatisticsView,
@ -47,10 +46,6 @@ urlpatterns = [
SearchAutoCompleteView.as_view(), SearchAutoCompleteView.as_view(),
name="autocomplete"), name="autocomplete"),
re_path(r"^search/",
SearchView.as_view(),
name="search"),
re_path(r"^statistics/", re_path(r"^statistics/",
StatisticsView.as_view(), StatisticsView.as_view(),
name="statistics"), name="statistics"),