Index: src/main/java/app/coffeetime/SearchInterface.java ================================================================== --- src/main/java/app/coffeetime/SearchInterface.java +++ src/main/java/app/coffeetime/SearchInterface.java @@ -6,17 +6,14 @@ import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.document.TextField; import org.apache.lucene.index.*; -import org.apache.lucene.search.BooleanClause; -import org.apache.lucene.search.BooleanQuery; -import org.apache.lucene.search.IndexSearcher; -import org.apache.lucene.search.TermQuery; +import org.apache.lucene.search.*; import org.apache.lucene.store.Directory; -import org.apache.lucene.store.FSDirectory; import org.apache.lucene.store.NIOFSDirectory; +import org.apache.lucene.util.QueryBuilder; import org.jetbrains.annotations.NotNull; import org.pcollections.*; import org.slf4j.Logger; import java.io.IOException; @@ -26,15 +23,32 @@ import java.util.Optional; import java.util.UUID; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; +import java.util.stream.Stream; import static app.coffeetime.util.Logging.logSupplier; public abstract class SearchInterface { private static final Supplier LOG = logSupplier(SearchInterface.class); + + public record SearchField(String name, Object value, boolean stored, boolean analyzed) { + public SearchField(final String name, final Object value) { + this(name, value, false, false); + } + } + + public record SearchResult(PMap searchFields) { + } + + public record SearchResults(long totalRecords, + PVector searchResults) { + } + + public static final SearchResults EMPTY_SEARCH_RESULTS = new SearchResults(0, TreePVector.empty()); + public static final SearchInterface SEARCH_NOOP = new SearchInterface() { @Override public void save(final PVector fields) { } @@ -46,20 +60,16 @@ @Override public Optional findDocument(final TreePVector searchFields) { return Optional.empty(); } - }; - - public record SearchField(String name, Object value, boolean stored) { - public SearchField(final String name, final Object value) { - this(name, value, false); - } - } - - public record SearchResult(PMap searchFields) { - } + + @Override + public SearchResults findDocuments(final TreePVector searchFields, int limit) { + return EMPTY_SEARCH_RESULTS; + } + }; public static SearchInterface disk(final String path, final PVector notAnalyzedFields) { try { final var indexPath = Path.of(path); @@ -119,10 +129,11 @@ public void save(final PVector fields) { try { final var document = new Document(); fields.forEach(field -> document.add(new Field(field.name(), String.valueOf(field.value()), field.stored() ? TextField.TYPE_STORED : TextField.TYPE_NOT_STORED))); + try (final var writer = writerSupplier.get()) { writer.addDocument(document); } } catch (IOException e) { throw new IllegalStateException(e); @@ -137,37 +148,53 @@ throw new IllegalStateException(e); } } @Override - public Optional findDocument(final TreePVector searchFields) { + public SearchResults findDocuments(final TreePVector searchFields, int limit) { try { final var queryBuilder = new BooleanQuery.Builder(); - searchFields.forEach(searchField -> { - queryBuilder.add(new BooleanClause(termQuery(searchField), BooleanClause.Occur.MUST)); - }); + searchFields.forEach(searchField -> + queryBuilder.add(new BooleanClause(buildQuery(analyzer, searchField), BooleanClause.Occur.MUST))); final var finalQuery = queryBuilder.build(); try (final var reader = readerSupplier.get()) { - final var searchResult = new IndexSearcher(reader).search(finalQuery, 1); + final var searchResult = new IndexSearcher(reader).search(finalQuery, limit); if (searchResult.totalHits.value > 0) { - final var document = reader.storedFields().document(searchResult.scoreDocs[0].doc); - return Optional.of(new SearchResult(document.getFields().stream() - .reduce(HashTreePMap.empty(), - (m, f) -> m.plus(f.name(), new SearchField(f.name(), f.stringValue())), - HashPMap::plusAll))); + return new SearchResults(searchResult.totalHits.value, + Stream.of(searchResult.scoreDocs[0]) + .map(scoreDoc -> { + try { + final var document = reader.storedFields().document(scoreDoc.doc); + return new SearchResult(document.getFields().stream() + .reduce(HashTreePMap.empty(), + (m, f) -> m.plus(f.name(), new SearchField(f.name(), f.stringValue())), + HashPMap::plusAll)); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }).reduce(TreePVector.empty(), TreePVector::plus, TreePVector::plusAll)); } else { - return Optional.empty(); + return EMPTY_SEARCH_RESULTS; } } } catch (IOException e) { throw new IllegalStateException(e); } } + + @Override + public Optional findDocument(final TreePVector searchFields) { + return findDocuments(searchFields, 1).searchResults().stream().findFirst(); + } @NotNull - private static TermQuery termQuery(final SearchField searchField) { + private static Query buildQuery(final Analyzer analyzer, final SearchField searchField) { + if (searchField.analyzed()) { + return new QueryBuilder(analyzer).createPhraseQuery(searchField.name(), + String.valueOf(searchField.value())); + } return new TermQuery(term(searchField)); } @NotNull private static Term term(final SearchField searchField) { @@ -183,6 +210,9 @@ public abstract void save(PVector fields); public abstract void delete(final SearchField field); public abstract Optional findDocument(final TreePVector searchFields); + + public abstract SearchResults findDocuments(final TreePVector searchFields, int limit); + } Index: src/main/java/app/coffeetime/models/WikiModel.java ================================================================== --- src/main/java/app/coffeetime/models/WikiModel.java +++ src/main/java/app/coffeetime/models/WikiModel.java @@ -2,11 +2,13 @@ import app.coffeetime.DatabaseInterface; import app.coffeetime.SearchInterface; import app.coffeetime.SearchInterface.SearchField; +import app.coffeetime.util.TextSanitizer; import org.apache.commons.io.IOUtils; +import org.jetbrains.annotations.NotNull; import org.pcollections.PVector; import org.pcollections.TreePVector; import org.slf4j.Logger; import java.io.IOException; @@ -15,16 +17,19 @@ import java.sql.PreparedStatement; import java.time.Instant; import java.util.Optional; import java.util.TimeZone; import java.util.function.Supplier; +import java.util.regex.Pattern; import static app.coffeetime.DatabaseInterface.*; import static app.coffeetime.util.Logging.logSupplier; +import static java.lang.String.format; import static java.util.Optional.ofNullable; public class WikiModel { + private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\[]+)]]"); private static final Supplier LOG = logSupplier(WikiModel.class); public static final String SEARCH_FIELD_LATEST = "wikiIsLatest"; public static final String SEARCH_FIELD_PAGE_ID = "wikiPageId"; public static final String SEARCH_FIELD_TEAM_ID = "wikiTeamId"; public static final String SEARCH_FIELD_PAGE_NAME = "wikiPageName"; @@ -75,58 +80,91 @@ .orElse("Welcome to your wiki!"); } }.get(); } - public record WikiPage(Integer id, String content, Integer version) { + public record WikiPage(Integer id, String name, String content, + Integer version) { } public static final String HOME_PAGE = "HomePage"; - private Optional getPageContents(final Connection conn, - final String orgId, - final String pageName) { + @NotNull + private static Optional loadPageContents(final Connection conn, final SearchInterface.SearchResult latestPage) { + final int pageId = Integer.parseInt(String.valueOf(latestPage.searchFields().get("wikiPageId").value())); + + return query(conn, "select * from organization_wiki_pages where id = ?", + stmt -> stmt.setInt(1, pageId), + rsi -> resultSetStream(rsi, () -> { + final var rs = rsi.getResultSet(); + return new WikiPage(pageId, + rs.getString("page_name"), + rs.getString("page_contents"), + rs.getInt("version")); + }).findFirst()); + } + + private Optional findLatestPage(final String orgId, final String pageName) { return search.findDocument(TreePVector.empty() - .plus(new SearchField(SEARCH_FIELD_TEAM_ID, orgId)) - .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName)) - .plus(new SearchField(SEARCH_FIELD_LATEST, true))) - .flatMap(searchResult -> { - final int pageId = Integer.parseInt(String.valueOf(searchResult.searchFields().get("wikiPageId").value())); - - return query(conn, "select * from organization_wiki_pages where id = ?", - stmt -> stmt.setInt(1, pageId), - rsi -> resultSetStream(rsi, () -> { - final var rs = rsi.getResultSet(); - return new WikiPage(pageId, rs.getString("page_contents"), rs.getInt("version")); - }).findFirst()); - }); + .plus(new SearchField(SEARCH_FIELD_TEAM_ID, orgId)) + .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName)) + .plus(new SearchField(SEARCH_FIELD_LATEST, true))); } public WikiPage getPage(final String requesterEmail, final String friendlyOrgId, final String pageName) { - return database.withConnection(true, conn -> + final var isAbleToSeePage = database.withConnection(true, conn -> userModel.getUserId(conn, requesterEmail) - .flatMap(userId -> teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId) - .filter(team -> team.isPubliclyVisible() || team.isRequesterMember()) - .map(team -> getPageContents(conn, team.getId(), pageName) - .orElseGet(() -> new WikiPage(null, HOME_PAGE.equals(pageName) ? - this.defaultHomeContent : - "There is no content here. Yet!", - null)))) - .orElse(null) - ); + .flatMap(userId -> teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId)) + .map(team -> team.isPubliclyVisible() || team.isRequesterMember()) + .orElse(false)); + + if (isAbleToSeePage) { + final var latestPage = findLatestPage(friendlyOrgId, pageName); + return latestPage + .map(searchResult -> database.withConnection(true, conn -> loadPageContents(conn, searchResult).orElse(null))) + .orElseGet(() -> new WikiPage(null, pageName, + HOME_PAGE.equals(pageName) ? + this.defaultHomeContent : + "There is no content here. Yet!", + null)); + } + return null; + } + + public PVector getBackLinks(final String requesterEmail, + final String friendlyOrgId, + final String pageName) { + final var teamO = database.withConnection(true, + conn -> teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId)); + if (teamO.filter(team -> team.isPubliclyVisible() || team.isRequesterMember()).isPresent()) { + final var documents = search.findDocuments(TreePVector.empty() + .plus(new SearchField(SEARCH_FIELD_TEAM_ID, friendlyOrgId)) + .plus(new SearchField(SEARCH_FIELD_CONTENT, pageName, false, true)), 50); + return database.withConnection(true, conn -> documents + .searchResults() + .stream() + .map(searchResult -> loadPageContents(conn, searchResult) + .orElseThrow(() -> new IllegalStateException(format("Document %s found in index but not database", + searchResult.searchFields())))) + .reduce(TreePVector.empty(), TreePVector::plus, TreePVector::plusAll)); + } else { + return TreePVector.empty(); + } } public WikiSaveResult savePage(final String requesterEmail, final String friendlyOrgId, final String pageName, final Integer currentVersion, final String pageContents) { + final var latestPage = findLatestPage(friendlyOrgId, pageName); return database.withTransaction(conn -> { final var userId = userModel.getUserId(conn, requesterEmail).orElseThrow(); + return teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId) .map(team -> { if (!team.isRequesterMember()) { return WikiSaveResult.accessDenied; } @@ -133,20 +171,20 @@ final var nowStr = nowSupplier.get().toString(); final var currentVersionOpt = ofNullable(currentVersion); - final var updateResult = getPageContents(conn, team.getId(), pageName) + final var updateResult = latestPage + .flatMap(result -> loadPageContents(conn, result)) .map(wikiPage -> currentVersionOpt.map(v -> { if (v.equals(wikiPage.version())) { markPageNotLatest(conn, pageName, team.getId(), wikiPage, nowStr); } else { return WikiSaveResult.collision; } return WikiSaveResult.success; - }).orElse(WikiSaveResult.collision)) - .orElseGet(() -> currentVersionOpt + }).orElse(WikiSaveResult.collision)).orElseGet(() -> currentVersionOpt .map(integer -> WikiSaveResult.pageMissing) .orElse(WikiSaveResult.success)); if (updateResult != WikiSaveResult.success) { return updateResult; @@ -182,17 +220,23 @@ final Integer pageId, final String pageName, final String pageContents, final int version, final boolean latest) { + final var processedContents = sanitizeWikiLinks(pageContents); search.save(TreePVector.empty() - .plus(new SearchField(SEARCH_FIELD_PAGE_ID, pageId, true)) - .plus(new SearchField(SEARCH_FIELD_TEAM_ID, teamId, true)) - .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName, true)) - .plus(new SearchField(SEARCH_FIELD_VERSION, version, true)) - .plus(new SearchField(SEARCH_FIELD_LATEST, latest, true)) - .plus(new SearchField(SEARCH_FIELD_CONTENT, pageContents))); + .plus(new SearchField(SEARCH_FIELD_PAGE_ID, pageId, true, false)) + .plus(new SearchField(SEARCH_FIELD_TEAM_ID, teamId, true, false)) + .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName, true, false)) + .plus(new SearchField(SEARCH_FIELD_VERSION, version, true, false)) + .plus(new SearchField(SEARCH_FIELD_LATEST, latest, true, false)) + .plus(new SearchField(SEARCH_FIELD_CONTENT, processedContents, false, true))); + } + + private static String sanitizeWikiLinks(final String pageContents) { + return WIKI_LINK_PATTERN.matcher(pageContents) + .replaceAll(matchResult -> TextSanitizer.wikiWord(matchResult.group(1))); } private void markPageNotLatest(final Connection conn, final String teamId, final String pageName, Index: src/main/java/app/coffeetime/web/WikiRouting.java ================================================================== --- src/main/java/app/coffeetime/web/WikiRouting.java +++ src/main/java/app/coffeetime/web/WikiRouting.java @@ -67,10 +67,28 @@ } catch (InvalidTokenException e) { LOG.get().error("Invalid CSRF token", e); return redirect("/", e.getUserFriendlyMessage()); } })); + + router.GET("/teams/:teamId/wiki/:pageName/_backLinks", wrapAuth(userModel, sessionProperties, + req -> { + final var teamId = req.requestPropertyAsString("teamId"); + final var requesterEmail = req.principal().orElse(null); + final var team = teamModel.getTeam(requesterEmail, req.timeZone(), teamId).orElseThrow(); + final var pageName = req.requestPropertyAsOptionalString("pageName").orElse(WikiModel.HOME_PAGE); + + return htmlResponse(renderPage(mergeTool, req, + "/templates/web/wikiBackLinks.vm", + HashTreePMap.empty() + .plus("homePage", WikiModel.HOME_PAGE.equals(pageName)) + .plus("homePageName", WikiModel.HOME_PAGE) + .plus("pageName", pageName) + .plus("team", team) + .plus("backLinks", wikiModel.getBackLinks(requesterEmail, teamId, pageName)))); + })); + } @NotNull private static Response wikiResponse(final MergeTool mergeTool, final WikiModel wikiModel, Index: src/main/resources/main.scss ================================================================== --- src/main/resources/main.scss +++ src/main/resources/main.scss @@ -1930,21 +1930,21 @@ } } #teamsPanel, #teamPanel, #wikiPanel { h1 { - a { + a:not(.headerText) { font-size: 1rem; line-height: 1rem; vertical-align: top; } } } @media (min-width: 420px) { #teamsPanel, #teamPanel, #wikiPanel { - h1 a { + h1 a:not(.headerText) { float: right; margin-left: 0.5rem; } } } ADDED src/main/resources/templates/web/wikiBackLinks.vm Index: src/main/resources/templates/web/wikiBackLinks.vm ================================================================== --- /dev/null +++ src/main/resources/templates/web/wikiBackLinks.vm @@ -0,0 +1,19 @@ +
+ + +
Index: src/main/resources/templates/web/wikiPage.vm ================================================================== --- src/main/resources/templates/web/wikiPage.vm +++ src/main/resources/templates/web/wikiPage.vm @@ -1,8 +1,8 @@