View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.dict.synonym;
17  
18  import java.io.BufferedReader;
19  import java.io.BufferedWriter;
20  import java.io.Closeable;
21  import java.io.File;
22  import java.io.FileOutputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.io.InputStreamReader;
26  import java.io.OutputStreamWriter;
27  import java.io.Writer;
28  import java.util.ArrayList;
29  import java.util.Collections;
30  import java.util.Date;
31  import java.util.List;
32  
33  import org.codelibs.core.io.CloseableUtil;
34  import org.codelibs.core.lang.StringUtil;
35  import org.codelibs.curl.CurlResponse;
36  import org.codelibs.fess.Constants;
37  import org.codelibs.fess.dict.DictionaryException;
38  import org.codelibs.fess.dict.DictionaryFile;
39  import org.codelibs.fess.util.ComponentUtil;
40  import org.dbflute.optional.OptionalEntity;
41  
42  /**
43   * Manages a dictionary file for synonyms.
44   * This class handles reading, parsing, and updating files that contain
45   * synonym rules. The file format supports both explicit mappings (e.g., `a => b`)
46   * and equivalent synonyms (e.g., `a, b, c`).
47   *
48   * The class provides methods for retrieving, adding, updating, and
49   * deleting synonym items, as well as reloading the dictionary
50   * from its source file.
51   */
52  public class SynonymFile extends DictionaryFile<SynonymItem> {
53      private static final String SYNONYM = "synonym";
54  
55      /** The list of synonym items loaded from the dictionary file. */
56      List<SynonymItem> synonymItemList;
57  
58      /**
59       * Constructs a new synonym file.
60       *
61       * @param id        The unique identifier for this dictionary file.
62       * @param path      The path to the dictionary file.
63       * @param timestamp The last modified timestamp of the file.
64       */
65      public SynonymFile(final String id, final String path, final Date timestamp) {
66          super(id, path, timestamp);
67      }
68  
69      @Override
70      public String getType() {
71          return SYNONYM;
72      }
73  
74      @Override
75      public String getPath() {
76          return path;
77      }
78  
79      @Override
80      public synchronized OptionalEntity<SynonymItem> get(final long id) {
81          if (synonymItemList == null) {
82              reload(null);
83          }
84  
85          for (final SynonymItem synonymItem : synonymItemList) {
86              if (id == synonymItem.getId()) {
87                  return OptionalEntity.of(synonymItem);
88              }
89          }
90          return OptionalEntity.empty();
91      }
92  
93      @Override
94      public synchronized PagingList<SynonymItem> selectList(final int offset, final int size) {
95          if (synonymItemList == null) {
96              reload(null);
97          }
98  
99          if (offset >= synonymItemList.size() || offset < 0) {
100             return new PagingList<>(Collections.<SynonymItem> emptyList(), offset, size, synonymItemList.size());
101         }
102 
103         int toIndex = offset + size;
104         if (toIndex > synonymItemList.size()) {
105             toIndex = synonymItemList.size();
106         }
107 
108         return new PagingList<>(synonymItemList.subList(offset, toIndex), offset, size, synonymItemList.size());
109     }
110 
111     @Override
112     public synchronized void insert(final SynonymItem item) {
113         try (SynonymUpdater updater = new SynonymUpdater(item)) {
114             reload(updater);
115         }
116     }
117 
118     @Override
119     public synchronized void update(final SynonymItem item) {
120         try (SynonymUpdater updater = new SynonymUpdater(item)) {
121             reload(updater);
122         }
123     }
124 
125     @Override
126     public synchronized void delete(final SynonymItem item) {
127         final SynonymItem synonymItem = item;
128         synonymItem.setNewInputs(StringUtil.EMPTY_STRINGS);
129         synonymItem.setNewOutputs(StringUtil.EMPTY_STRINGS);
130         try (SynonymUpdater updater = new SynonymUpdater(item)) {
131             reload(updater);
132         }
133     }
134 
135     /**
136      * Reloads the synonym dictionary from its source file.
137      *
138      * @param updater An optional updater to apply changes during reload.
139      * @throws DictionaryException if the dictionary file cannot be read.
140      */
141     protected void reload(final SynonymUpdater updater) {
142         try (CurlResponse curlResponse = dictionaryManager.getContentResponse(this)) {
143             reload(updater, curlResponse.getContentAsStream());
144         } catch (final IOException e) {
145             throw new DictionaryException("Failed to parse " + path, e);
146         }
147     }
148 
149     /**
150      * Reloads the synonym dictionary from an input stream.
151      *
152      * @param updater An optional updater to apply changes.
153      * @param in      The input stream to read the dictionary from.
154      * @throws DictionaryException if the input stream cannot be parsed.
155      */
156     protected void reload(final SynonymUpdater updater, final InputStream in) {
157         final List<SynonymItem> itemList = new ArrayList<>();
158         try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, Constants.UTF_8))) {
159             long id = 0;
160             String line = null;
161             while ((line = reader.readLine()) != null) {
162                 if (line.length() == 0 || line.charAt(0) == '#') {
163                     if (updater != null) {
164                         updater.write(line);
165                     }
166                     continue; // ignore empty lines and comments
167                 }
168 
169                 String[] inputs;
170                 String[] outputs;
171 
172                 final List<String> sides = split(line, "=>");
173                 if (sides.size() > 1) { // explicit mapping
174                     if (sides.size() != 2) {
175                         throw new DictionaryException("more than one explicit mapping specified on the same line");
176                     }
177                     final List<String> inputStrings = split(sides.get(0), ",");
178                     inputs = new String[inputStrings.size()];
179                     for (int i = 0; i < inputs.length; i++) {
180                         inputs[i] = unescape(inputStrings.get(i)).trim();
181                     }
182 
183                     final List<String> outputStrings = split(sides.get(1), ",");
184                     outputs = new String[outputStrings.size()];
185                     for (int i = 0; i < outputs.length; i++) {
186                         outputs[i] = unescape(outputStrings.get(i)).trim();
187                     }
188 
189                     if (inputs.length > 0 && outputs.length > 0) {
190                         id++;
191                         final SynonymItem item = new SynonymItem(id, inputs, outputs);
192                         if (updater != null) {
193                             final SynonymItem newItem = updater.write(item);
194                             if (newItem != null) {
195                                 itemList.add(newItem);
196                             } else {
197                                 id--;
198                             }
199                         } else {
200                             itemList.add(item);
201                         }
202                     }
203                 } else {
204                     final List<String> inputStrings = split(line, ",");
205                     inputs = new String[inputStrings.size()];
206                     for (int i = 0; i < inputs.length; i++) {
207                         inputs[i] = unescape(inputStrings.get(i)).trim();
208                     }
209 
210                     if (inputs.length > 0) {
211                         id++;
212                         final SynonymItem item = new SynonymItem(id, inputs, inputs);
213                         if (updater != null) {
214                             final SynonymItem newItem = updater.write(item);
215                             if (newItem != null) {
216                                 itemList.add(newItem);
217                             } else {
218                                 id--;
219                             }
220                         } else {
221                             itemList.add(item);
222                         }
223                     }
224                 }
225             }
226             if (updater != null) {
227                 final SynonymItem item = updater.commit();
228                 if (item != null) {
229                     itemList.add(item);
230                 }
231             }
232             synonymItemList = itemList;
233         } catch (final IOException e) {
234             throw new DictionaryException("Failed to parse " + path, e);
235         }
236     }
237 
238     private static List<String> split(final String s, final String separator) {
239         final List<String> list = new ArrayList<>(2);
240         StringBuilder sb = new StringBuilder();
241         int pos = 0;
242         final int end = s.length();
243         while (pos < end) {
244             if (s.startsWith(separator, pos)) {
245                 if (sb.length() > 0) {
246                     list.add(sb.toString());
247                     sb = new StringBuilder();
248                 }
249                 pos += separator.length();
250                 continue;
251             }
252 
253             char ch = s.charAt(pos);
254             pos++;
255             if (ch == '\\') {
256                 sb.append(ch);
257                 if (pos >= end) {
258                     break; // ERROR, or let it go?
259                 }
260                 ch = s.charAt(pos);
261                 pos++;
262             }
263 
264             sb.append(ch);
265         }
266 
267         if (sb.length() > 0) {
268             list.add(sb.toString());
269         }
270 
271         return list;
272     }
273 
274     private String unescape(final String s) {
275         if (s.indexOf('\\') >= 0) {
276             final StringBuilder sb = new StringBuilder();
277             for (int i = 0; i < s.length(); i++) {
278                 final char ch = s.charAt(i);
279                 if (ch == '\\' && i < s.length() - 1) {
280                     i++;
281                     sb.append(s.charAt(i));
282                 } else {
283                     sb.append(ch);
284                 }
285             }
286             return sb.toString();
287         }
288         return s;
289     }
290 
291     /**
292      * Returns the simple name of the dictionary file.
293      *
294      * @return The file name without the path.
295      */
296     public String getSimpleName() {
297         return new File(path).getName();
298     }
299 
300     /**
301      * Updates the dictionary file with content from an input stream.
302      *
303      * @param in The input stream containing the new dictionary content.
304      * @throws IOException if an I/O error occurs.
305      */
306     public synchronized void update(final InputStream in) throws IOException {
307         try (SynonymUpdater updater = new SynonymUpdater(null)) {
308             reload(updater, in);
309         }
310     }
311 
312     @Override
313     public String toString() {
314         return "SynonymFile [path=" + path + ", synonymItemList=" + synonymItemList + ", id=" + id + "]";
315     }
316 
317     /**
318      * An inner class for updating the synonym file.
319      * This class handles the process of writing changes to a temporary file
320      * and then replacing the original file upon successful commit.
321      */
322     protected class SynonymUpdater implements Closeable {
323 
324         /** A flag indicating whether the changes have been committed. */
325         protected boolean isCommit = false;
326 
327         /** The temporary file to write changes to. */
328         protected File newFile;
329 
330         /** The writer for the temporary file. */
331         protected Writer writer;
332 
333         /** The synonym item being added or updated. */
334         protected SynonymItem item;
335 
336         /**
337          * Constructs a new updater for a synonym item.
338          *
339          * @param newItem The item to be added or updated.
340          * @throws DictionaryException if the temporary file cannot be created.
341          */
342         protected SynonymUpdater(final SynonymItem newItem) {
343             FileOutputStream fos = null;
344             try {
345                 newFile = ComponentUtil.getSystemHelper().createTempFile(SYNONYM, ".txt");
346                 fos = new FileOutputStream(newFile);
347                 writer = new BufferedWriter(new OutputStreamWriter(fos, Constants.UTF_8));
348                 fos = null; // Successfully wrapped, no need to close explicitly
349             } catch (final Exception e) {
350                 if (fos != null) {
351                     try {
352                         fos.close();
353                     } catch (final IOException ioe) {
354                         // Ignore close exception
355                     }
356                 }
357                 if (newFile != null) {
358                     newFile.delete();
359                 }
360                 throw new DictionaryException("Failed to write a userDict file.", e);
361             }
362             item = newItem;
363         }
364 
365         /**
366          * Writes a synonym item to the temporary file.
367          * If the item is being updated, it writes the new version.
368          *
369          * @param oldItem The original item from the dictionary.
370          * @return The written item, or null if the item was deleted.
371          * @throws DictionaryException if the file was updated concurrently.
372          */
373         public SynonymItem write(final SynonymItem oldItem) {
374             try {
375                 if (item == null || item.getId() != oldItem.getId() || !item.isUpdated()) {
376                     writer.write(oldItem.toLineString());
377                     writer.write(Constants.LINE_SEPARATOR);
378                     return oldItem;
379                 }
380                 if (!item.equals(oldItem)) {
381                     throw new DictionaryException("Synonym file was updated: old=" + oldItem + " : new=" + item);
382                 }
383                 try {
384                     if (!item.isDeleted()) {
385                         // update
386                         writer.write(item.toLineString());
387                         writer.write(Constants.LINE_SEPARATOR);
388                         return new SynonymItem(item.getId(), item.getNewInputs(), item.getNewOutputs());
389                     }
390                     return null;
391                 } finally {
392                     item.setNewInputs(null);
393                     item.setNewOutputs(null);
394                 }
395             } catch (final IOException e) {
396                 throw new DictionaryException("Failed to write: " + oldItem + " -> " + item, e);
397             }
398         }
399 
400         /**
401          * Writes a raw line to the temporary file.
402          *
403          * @param line The line to write.
404          * @throws DictionaryException if an I/O error occurs.
405          */
406         public void write(final String line) {
407             try {
408                 writer.write(line);
409                 writer.write(Constants.LINE_SEPARATOR);
410             } catch (final IOException e) {
411                 throw new DictionaryException("Failed to write: " + line, e);
412             }
413         }
414 
415         /**
416          * Commits the changes to the dictionary file.
417          * If there is a pending new item, it is written to the file.
418          *
419          * @return The committed item, or null if no item was committed.
420          * @throws DictionaryException if an I/O error occurs.
421          */
422         public SynonymItem commit() {
423             isCommit = true;
424             if (item != null && item.isUpdated()) {
425                 try {
426                     writer.write(item.toLineString());
427                     writer.write(Constants.LINE_SEPARATOR);
428                     return item;
429                 } catch (final IOException e) {
430                     throw new DictionaryException("Failed to write: " + item, e);
431                 }
432             }
433             return null;
434         }
435 
436         @Override
437         public void close() {
438             try {
439                 writer.flush();
440             } catch (final IOException e) {
441                 // ignore
442             }
443             CloseableUtil.closeQuietly(writer);
444 
445             if (isCommit) {
446                 try {
447                     dictionaryManager.store(SynonymFile.this, newFile);
448                 } finally {
449                     newFile.delete();
450                 }
451             } else {
452                 newFile.delete();
453             }
454         }
455     }
456 
457 }