Coffee Time

Changes On Branch wiki
Login

Changes On Branch wiki

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Changes In Branch wiki Excluding Merge-Ins

This is equivalent to a diff from ad120f4256 to c6f863dd80

2024-08-04
21:11
Merged back links check-in: c743e4b553 user: scstarkey tags: main, trunk
21:11
Back links Closed-Leaf check-in: c6f863dd80 user: scstarkey tags: wiki
2024-08-02
14:40
Brought fixes back in from main check-in: 993637cf98 user: scstarkey tags: wiki
14:31
Switched to basic from standard check-in: ad120f4256 user: scstarkey tags: main, trunk
14:03
Switching to NIOFSDirectory to see if it'll start up in podman container check-in: a2f0bd53de user: scstarkey tags: main, trunk

Changes to src/main/java/app/coffeetime/SearchInterface.java.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

31
32
33
34
35
















36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

54
55

56
57
58
59
60
61
62
63
64
65
66
67
package app.coffeetime;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
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.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.NIOFSDirectory;
import org.jetbrains.annotations.NotNull;
import org.pcollections.*;
import org.slf4j.Logger;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;


import static app.coffeetime.util.Logging.logSupplier;

public abstract class SearchInterface {
    private static final Supplier<Logger> LOG = logSupplier(SearchInterface.class);
















    public static final SearchInterface SEARCH_NOOP = new SearchInterface() {
        @Override
        public void save(final PVector<SearchField> fields) {

        }

        @Override
        public void delete(final SearchField field) {

        }

        @Override
        public Optional<SearchResult> findDocument(final TreePVector<SearchField> 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<String, SearchField> searchFields) {
    }

    public static SearchInterface disk(final String path,
                                       final PVector<String> notAnalyzedFields) {
        try {
            final var indexPath = Path.of(path);
            final var indexFile = indexPath.toFile();











|
<
<
<

|
|













>





>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>















<

<
>
|
<
>

|
<
<
<







1
2
3
4
5
6
7
8
9
10
11



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

65

66
67

68
69
70



71
72
73
74
75
76
77
package app.coffeetime;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
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.*;



import org.apache.lucene.store.Directory;
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;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
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<Logger> 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<String, SearchField> searchFields) {
    }

    public record SearchResults(long totalRecords,
                                PVector<SearchResult> 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<SearchField> fields) {

        }

        @Override
        public void delete(final SearchField field) {

        }

        @Override
        public Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields) {
            return Optional.empty();
        }



        @Override
        public SearchResults findDocuments(final TreePVector<SearchField> searchFields, int limit) {

            return EMPTY_SEARCH_RESULTS;
        }
    };




    public static SearchInterface disk(final String path,
                                       final PVector<String> notAnalyzedFields) {
        try {
            final var indexPath = Path.of(path);
            final var indexFile = indexPath.toFile();

117
118
119
120
121
122
123

124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152




153
154
155
156
157




158
159
160
161
162
163
164
165
166





167
168




169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188



            return new SearchInterface() {
                @Override
                public void save(final PVector<SearchField> 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);
                    }
                }

                @Override
                public void delete(final SearchField field) {
                    try (final var writer = writerSupplier.get()) {
                        writer.deleteDocuments(term(field));
                    } catch (IOException e) {
                        throw new IllegalStateException(e);
                    }
                }

                @Override
                public Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields) {
                    try {
                        final var queryBuilder = new BooleanQuery.Builder();
                        searchFields.forEach(searchField -> {
                            queryBuilder.add(new BooleanClause(termQuery(searchField), BooleanClause.Occur.MUST));
                        });
                        final var finalQuery = queryBuilder.build();

                        try (final var reader = readerSupplier.get()) {
                            final var searchResult = new IndexSearcher(reader).search(finalQuery, 1);
                            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)));




                            } else {
                                return Optional.empty();
                            }
                        }
                    } catch (IOException e) {
                        throw new IllegalStateException(e);
                    }
                }






                @NotNull
                private static TermQuery termQuery(final SearchField searchField) {




                    return new TermQuery(term(searchField));
                }

                @NotNull
                private static Term term(final SearchField searchField) {
                    return new Term(searchField.name(), String.valueOf(searchField.value()));
                }
            };
        } catch (Throwable t) {
            LOG.get().error("Unable to initialize search engine", t);
            return SEARCH_NOOP;
        }
    }

    public abstract void save(PVector<SearchField> fields);

    public abstract void delete(final SearchField field);

    public abstract Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields);
}










>


















|


|
|
<



|

>
>
>
>
|
|
|
|
|
>
>
>
>

|







>
>
>
>
>

|
>
>
>
>



















|
>
>
>
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157

158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
            return new SearchInterface() {
                @Override
                public void save(final PVector<SearchField> 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);
                    }
                }

                @Override
                public void delete(final SearchField field) {
                    try (final var writer = writerSupplier.get()) {
                        writer.deleteDocuments(term(field));
                    } catch (IOException e) {
                        throw new IllegalStateException(e);
                    }
                }

                @Override
                public SearchResults findDocuments(final TreePVector<SearchField> searchFields, int limit) {
                    try {
                        final var queryBuilder = new BooleanQuery.Builder();
                        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, limit);
                            if (searchResult.totalHits.value > 0) {
                                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 EMPTY_SEARCH_RESULTS;
                            }
                        }
                    } catch (IOException e) {
                        throw new IllegalStateException(e);
                    }
                }

                @Override
                public Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields) {
                    return findDocuments(searchFields, 1).searchResults().stream().findFirst();
                }

                @NotNull
                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) {
                    return new Term(searchField.name(), String.valueOf(searchField.value()));
                }
            };
        } catch (Throwable t) {
            LOG.get().error("Unable to initialize search engine", t);
            return SEARCH_NOOP;
        }
    }

    public abstract void save(PVector<SearchField> fields);

    public abstract void delete(final SearchField field);

    public abstract Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields);

    public abstract SearchResults findDocuments(final TreePVector<SearchField> searchFields, int limit);

}

Changes to src/main/java/app/coffeetime/models/WikiModel.java.

1
2
3
4
5
6

7

8
9
10
11
12
13
14
15
16
17
18
19

20
21
22

23
24
25

26
27
28
29
30
31
32
package app.coffeetime.models;


import app.coffeetime.DatabaseInterface;
import app.coffeetime.SearchInterface;
import app.coffeetime.SearchInterface.SearchField;

import org.apache.commons.io.IOUtils;

import org.pcollections.PVector;
import org.pcollections.TreePVector;
import org.slf4j.Logger;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.time.Instant;
import java.util.Optional;
import java.util.TimeZone;
import java.util.function.Supplier;


import static app.coffeetime.DatabaseInterface.*;
import static app.coffeetime.util.Logging.logSupplier;

import static java.util.Optional.ofNullable;

public class WikiModel {

    private static final Supplier<Logger> 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";
    public static final String SEARCH_FIELD_VERSION = "wikiPageVersion";
    public static final String SEARCH_FIELD_CONTENT = "wikiContent";






>

>












>



>



>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package app.coffeetime.models;


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;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
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<Logger> 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";
    public static final String SEARCH_FIELD_VERSION = "wikiPageVersion";
    public static final String SEARCH_FIELD_CONTENT = "wikiContent";
73
74
75
76
77
78
79
80

81
82
83
84
85

86
87
88
89
90
91
92
93
94
95
96
97
98
99
100



101

102





103
104
105
106
107
108
109
110
111

112




113

114
115
116

117

118



















119
120
121
122
123
124
125

126
127

128
129
130
131
132
133
134
135
136
137
138

139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
                            }
                        })
                        .orElse("Welcome to your wiki!");
            }
        }.get();
    }

    public record WikiPage(Integer id, String content, Integer version) {


    }

    public static final String HOME_PAGE = "HomePage";


    private Optional<WikiPage> getPageContents(final Connection conn,
                                               final String orgId,
                                               final String pageName) {
        return search.findDocument(TreePVector.<SearchField>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());

                });





    }

    public WikiPage getPage(final String requesterEmail,
                            final String friendlyOrgId,
                            final String pageName) {
        return 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)

        );



















    }

    public WikiSaveResult savePage(final String requesterEmail,
                                   final String friendlyOrgId,
                                   final String pageName,
                                   final Integer currentVersion,
                                   final String pageContents) {

        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;
                        }

                        final var nowStr = nowSupplier.get().toString();

                        final var currentVersionOpt = ofNullable(currentVersion);

                        final var updateResult = getPageContents(conn, team.getId(), pageName)

                                .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
                                        .map(integer -> WikiSaveResult.pageMissing)
                                        .orElse(WikiSaveResult.success));

                        if (updateResult != WikiSaveResult.success) {
                            return updateResult;
                        }








|
>





>
|
<
<
<
<
<
<
<
|

|
|
|
|
|
>
>
>
|
>
|
>
>
>
>
>





|

|
|
>
|
>
>
>
>
|
>
|
|
|
>
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







>


>










|
>







|
<







78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93







94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185

186
187
188
189
190
191
192
                            }
                        })
                        .orElse("Welcome to your wiki!");
            }
        }.get();
    }

    public record WikiPage(Integer id, String name, String content,
                           Integer version) {

    }

    public static final String HOME_PAGE = "HomePage";

    @NotNull
    private static Optional<WikiPage> 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<SearchInterface.SearchResult> findLatestPage(final String orgId, final String pageName) {
        return search.findDocument(TreePVector.<SearchField>empty()
                .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) {
        final var isAbleToSeePage = database.withConnection(true, conn ->
                userModel.getUserId(conn, requesterEmail)
                        .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<WikiPage> 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.<SearchField>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;
                        }

                        final var nowStr = nowSupplier.get().toString();

                        final var currentVersionOpt = ofNullable(currentVersion);

                        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

                                        .map(integer -> WikiSaveResult.pageMissing)
                                        .orElse(WikiSaveResult.success));

                        if (updateResult != WikiSaveResult.success) {
                            return updateResult;
                        }

180
181
182
183
184
185
186

187
188
189
190
191
192
193





194
195
196
197
198
199
200

    private void indexPage(final String teamId,
                           final Integer pageId,
                           final String pageName,
                           final String pageContents,
                           final int version,
                           final boolean latest) {

        search.save(TreePVector.<SearchField>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)));





    }

    private void markPageNotLatest(final Connection conn,
                                   final String teamId,
                                   final String pageName,
                                   final WikiPage page,
                                   final String nowStr) {







>

|
|
|
|
|
|
>
>
>
>
>







218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244

    private void indexPage(final String teamId,
                           final Integer pageId,
                           final String pageName,
                           final String pageContents,
                           final int version,
                           final boolean latest) {
        final var processedContents = sanitizeWikiLinks(pageContents);
        search.save(TreePVector.<SearchField>empty()
                .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,
                                   final WikiPage page,
                                   final String nowStr) {

Changes to src/main/java/app/coffeetime/web/WikiRouting.java.

65
66
67
68
69
70
71


















72
73
74
75
76
77
78
                                    });
                        }
                    } catch (InvalidTokenException e) {
                        LOG.get().error("Invalid CSRF token", e);
                        return redirect("/", e.getUserFriendlyMessage());
                    }
                }));


















    }

    @NotNull
    private static Response wikiResponse(final MergeTool mergeTool,
                                         final WikiModel wikiModel,
                                         final TeamModel teamModel,
                                         final Request req,







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
                                    });
                        }
                    } 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.<String, Object>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,
                                         final TeamModel teamModel,
                                         final Request req,

Changes to src/main/resources/main.scss.

1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
    grid-row-gap: 1rem;
    grid-template-columns: min-content;
  }
}

#teamsPanel, #teamPanel, #wikiPanel {
  h1 {
    a {
      font-size: 1rem;
      line-height: 1rem;
      vertical-align: top;
    }
  }
}

@media (min-width: 420px) {
  #teamsPanel, #teamPanel, #wikiPanel {
    h1 a {
      float: right;
      margin-left: 0.5rem;
    }
  }
}

@media (max-width: 419px) {







|









|







1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
    grid-row-gap: 1rem;
    grid-template-columns: min-content;
  }
}

#teamsPanel, #teamPanel, #wikiPanel {
  h1 {
    a:not(.headerText) {
      font-size: 1rem;
      line-height: 1rem;
      vertical-align: top;
    }
  }
}

@media (min-width: 420px) {
  #teamsPanel, #teamPanel, #wikiPanel {
    h1 a:not(.headerText) {
      float: right;
      margin-left: 0.5rem;
    }
  }
}

@media (max-width: 419px) {

Added src/main/resources/templates/web/wikiBackLinks.vm.







































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="wikiPanel">
    <div id="header">
        <h1><a class="headerText" href="/teams/${team.id}/wiki/${pageName}">${pageName}</a>
            references
            <a href="/teams/${team.id}">${team.name}</a>
            #if(!${homePage})
                <a href="/teams/${team.id}/wiki">${homePageName}</a>
            #end
            #if(${team.requesterMember})
                <a href="/teams/${team.id}/wiki/${pageName}/_edit">edit</a>
            #end
        </h1>
    </div>
    <ul>
        #foreach($page in ${backLinks})
           <li> <a class="headerText" href="/teams/${team.id}/wiki/${page.name()}">${page.name()}</a></li>
        #end
    </ul>
</div>

Changes to src/main/resources/templates/web/wikiPage.vm.

1
2
3
4
5
6
7
8
9
10
<div id="wikiPanel">
    <div id="header">
        <h1>${pageName}
            <a href="/teams/${team.id}">${team.name}</a>
            #if(!${homePage})
                <a href="/teams/${team.id}/wiki">${homePageName}</a>
            #end
            #if(${team.requesterMember})
                <a href="/teams/${team.id}/wiki/${pageName}/_edit">edit</a>
            #end


|







1
2
3
4
5
6
7
8
9
10
<div id="wikiPanel">
    <div id="header">
        <h1><a class="headerText" href="/teams/${team.id}/wiki/${pageName}/_backLinks">${pageName}</a>
            <a href="/teams/${team.id}">${team.name}</a>
            #if(!${homePage})
                <a href="/teams/${team.id}/wiki">${homePageName}</a>
            #end
            #if(${team.requesterMember})
                <a href="/teams/${team.id}/wiki/${pageName}/_edit">edit</a>
            #end

Changes to src/test/java/app/coffeetime/models/WikiModelTest.java.

1
2
3
4
5
6
7
8



9
10
11
12
13
14
15

16
17
18
19
20

21
22
23
24
25
26
27
package app.coffeetime.models;

import app.coffeetime.fixtures.TestCoffeeTime;
import app.coffeetime.util.Logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.ConsoleAppender;
import io.sentry.SentryOptions;



import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.pcollections.HashTreePMap;
import org.pcollections.TreePVector;

import java.time.Instant;

import java.util.TimeZone;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;

import static app.coffeetime.fixtures.TestCoffeeTime.ADMIN_EMAIL;

import static org.junit.jupiter.api.Assertions.*;

public class WikiModelTest {
    private static final Instant EXPECTED_NOW = Instant.parse("2024-06-10T12:30:00Z");
    private static final AtomicReference<Instant> CURRENT_NOW = new AtomicReference<>();
    private static final String DEFAULT_HOME_CONTENT_SNIPPET = "Welcome to your wiki!";
    private static final String DEFAULT_NON_HOME_CONTENT_SNIPPET = "There is no content here";








>
>
>







>





>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package app.coffeetime.models;

import app.coffeetime.fixtures.TestCoffeeTime;
import app.coffeetime.util.Logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.ConsoleAppender;
import io.sentry.SentryOptions;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.util.AttributeImpl;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.pcollections.HashTreePMap;
import org.pcollections.TreePVector;

import java.time.Instant;
import java.util.Iterator;
import java.util.TimeZone;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;

import static app.coffeetime.fixtures.TestCoffeeTime.ADMIN_EMAIL;
import static app.coffeetime.models.WikiModel.HOME_PAGE;
import static org.junit.jupiter.api.Assertions.*;

public class WikiModelTest {
    private static final Instant EXPECTED_NOW = Instant.parse("2024-06-10T12:30:00Z");
    private static final AtomicReference<Instant> CURRENT_NOW = new AtomicReference<>();
    private static final String DEFAULT_HOME_CONTENT_SNIPPET = "Welcome to your wiki!";
    private static final String DEFAULT_NON_HOME_CONTENT_SNIPPET = "There is no content here";
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107

108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162

163
164
165
166
167
168
169

        teamModel.addMember(ADMIN_EMAIL, this.publicTeam.getId(), nonAdminMemberUser.userInfo());
        teamModel.addMember(ADMIN_EMAIL, this.privateTeam.getId(), nonAdminMemberUser.userInfo());
    }

    @Test
    public void missingHomePage() {
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);

        assertTrue(page.content().contains(DEFAULT_HOME_CONTENT_SNIPPET));
        assertNull(page.version());
    }

    @Test
    public void newHomePage() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, expectedContent);
        final var secondTeamHome = wikiModel.getPage(ADMIN_EMAIL, privateTeam.getId(), WikiModel.HOME_PAGE);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);

        assertTrue(secondTeamHome.content().contains(DEFAULT_HOME_CONTENT_SNIPPET), secondTeamHome.toString());

        assertEquals(expectedContent, page.content());
        assertEquals(1, page.version());
    }

    @Test
    public void existingPage() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, "First update ignored");
        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, 1, expectedContent);

        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);

        assertEquals(expectedContent, secondPage.content());
        assertEquals(2, secondPage.version());
    }

    @Test
    public void conflictingUpdatesRejected() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, "First update ignored");
        final var firstResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, 1, expectedContent);
        final var secondResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, 1, "Bad update");
        final var thirdResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, "Bad update");
        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);

        assertEquals(WikiModel.WikiSaveResult.success, firstResult);
        assertEquals(WikiModel.WikiSaveResult.collision, secondResult);
        assertEquals(WikiModel.WikiSaveResult.collision, thirdResult);
        assertEquals(expectedContent, secondPage.content());
        assertEquals(2, secondPage.version());
    }

    @Test
    public void nonexistentPreviousVersion() {
        final var teamId = publicTeam.getId();
        final var saveResult = wikiModel.savePage(ADMIN_EMAIL, teamId, WikiModel.HOME_PAGE, 2, "Bad save");
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);

        assertEquals(WikiModel.WikiSaveResult.pageMissing, saveResult);
        assertTrue(page.content().contains(DEFAULT_HOME_CONTENT_SNIPPET));
        assertNull(page.version());
    }

    @Test
    public void updateNotHomePage() {
        final var pageName = "NotHome";
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), pageName, null, expectedContent);
        final var secondTeamPage = wikiModel.getPage(ADMIN_EMAIL, privateTeam.getId(), pageName);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), pageName);

        assertTrue(secondTeamPage.content().contains(DEFAULT_NON_HOME_CONTENT_SNIPPET), secondTeamPage.toString());

        assertEquals(expectedContent, page.content());
        assertEquals(1, page.version());
    }

    @Test
    public void nonAdminsCanUpdateAndView() {
        final var expectedPublicContent = "This is our public wiki";







|









|
|
|


>








|
|

|









|
|
|
|
|











|
|
















>







91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176

        teamModel.addMember(ADMIN_EMAIL, this.publicTeam.getId(), nonAdminMemberUser.userInfo());
        teamModel.addMember(ADMIN_EMAIL, this.privateTeam.getId(), nonAdminMemberUser.userInfo());
    }

    @Test
    public void missingHomePage() {
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertTrue(page.content().contains(DEFAULT_HOME_CONTENT_SNIPPET));
        assertNull(page.version());
    }

    @Test
    public void newHomePage() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, expectedContent);
        final var secondTeamHome = wikiModel.getPage(ADMIN_EMAIL, privateTeam.getId(), HOME_PAGE);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertTrue(secondTeamHome.content().contains(DEFAULT_HOME_CONTENT_SNIPPET), secondTeamHome.toString());
        assertEquals(HOME_PAGE, page.name());
        assertEquals(expectedContent, page.content());
        assertEquals(1, page.version());
    }

    @Test
    public void existingPage() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, "First update ignored");
        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, 1, expectedContent);

        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertEquals(expectedContent, secondPage.content());
        assertEquals(2, secondPage.version());
    }

    @Test
    public void conflictingUpdatesRejected() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, "First update ignored");
        final var firstResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, 1, expectedContent);
        final var secondResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, 1, "Bad update");
        final var thirdResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, "Bad update");
        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertEquals(WikiModel.WikiSaveResult.success, firstResult);
        assertEquals(WikiModel.WikiSaveResult.collision, secondResult);
        assertEquals(WikiModel.WikiSaveResult.collision, thirdResult);
        assertEquals(expectedContent, secondPage.content());
        assertEquals(2, secondPage.version());
    }

    @Test
    public void nonexistentPreviousVersion() {
        final var teamId = publicTeam.getId();
        final var saveResult = wikiModel.savePage(ADMIN_EMAIL, teamId, HOME_PAGE, 2, "Bad save");
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertEquals(WikiModel.WikiSaveResult.pageMissing, saveResult);
        assertTrue(page.content().contains(DEFAULT_HOME_CONTENT_SNIPPET));
        assertNull(page.version());
    }

    @Test
    public void updateNotHomePage() {
        final var pageName = "NotHome";
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), pageName, null, expectedContent);
        final var secondTeamPage = wikiModel.getPage(ADMIN_EMAIL, privateTeam.getId(), pageName);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), pageName);

        assertTrue(secondTeamPage.content().contains(DEFAULT_NON_HOME_CONTENT_SNIPPET), secondTeamPage.toString());
        assertEquals(pageName, page.name());
        assertEquals(expectedContent, page.content());
        assertEquals(1, page.version());
    }

    @Test
    public void nonAdminsCanUpdateAndView() {
        final var expectedPublicContent = "This is our public wiki";
216
217
218
219
220
221
222


223
























224
225
226
227
228
229
230

        assertNull(wikiModel.getPage(null, publicTeam.getId(), pageName));
        assertNull(wikiModel.getPage(null, privateTeam.getId(), pageName));
    }

    @Test
    public void backLinks() {



























    }

    @Test
    public void searchAmongLatestPages() {

    }








>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263

        assertNull(wikiModel.getPage(null, publicTeam.getId(), pageName));
        assertNull(wikiModel.getPage(null, privateTeam.getId(), pageName));
    }

    @Test
    public void backLinks() {
        final var expectedPublicContents = "This is a public page that refers to [[AnotherPage]] that is of note";
        final var expectedPrivateContents = "This is a private page that refers to [[Another Page]] that is of note";

        wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, publicTeam.getId(), "HomePage", null,
                "This is a page without any links");
        final var publicLinkedResult = wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, publicTeam.getId(), "HomePage", 1,
                expectedPublicContents);
        final var privateLinkedResult = wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, privateTeam.getId(), "HomePage", null,
                expectedPrivateContents);
        wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, publicTeam.getId(), "AnotherPage", null,
                "This is another fun page");

        final var publicBackLinks = wikiModel.getBackLinks(NON_TEAM_MEMBER, publicTeam.getId(), "AnotherPage");
        final var privateBackLinks = wikiModel.getBackLinks(ADMIN_EMAIL, privateTeam.getId(), "AnotherPage");
        final var notAllowedBackLinks = wikiModel.getBackLinks(NON_TEAM_MEMBER, privateTeam.getId(), "AnotherPage");

        assertEquals(WikiModel.WikiSaveResult.success, publicLinkedResult);
        assertEquals(WikiModel.WikiSaveResult.success, privateLinkedResult);

        assertEquals(1, publicBackLinks.size());
        assertEquals(1, privateBackLinks.size());
        assertEquals(0, notAllowedBackLinks.size());

        assertEquals(expectedPublicContents, publicBackLinks.getFirst().content());
        assertEquals(HOME_PAGE, publicBackLinks.getFirst().name());
        assertEquals(expectedPrivateContents, privateBackLinks.getFirst().content());
        assertEquals(HOME_PAGE, privateBackLinks.getFirst().name());
    }

    @Test
    public void searchAmongLatestPages() {

    }