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.mapping;
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  import java.util.regex.Matcher;
33  import java.util.regex.Pattern;
34  
35  import org.apache.logging.log4j.LogManager;
36  import org.apache.logging.log4j.Logger;
37  import org.codelibs.core.io.CloseableUtil;
38  import org.codelibs.core.lang.StringUtil;
39  import org.codelibs.curl.CurlResponse;
40  import org.codelibs.fess.Constants;
41  import org.codelibs.fess.dict.DictionaryException;
42  import org.codelibs.fess.dict.DictionaryFile;
43  import org.codelibs.fess.util.ComponentUtil;
44  import org.dbflute.optional.OptionalEntity;
45  
46  /**
47   * Character mapping file handler for managing character mapping dictionaries.
48   * This class provides functionality to load, parse, and manage character mapping
49   * rules that define how input characters should be transformed to output characters
50   * during text analysis and search processing.
51   *
52   * Character mapping files contain mapping rules in the format:
53   * input1,input2,... => output
54   */
55  public class CharMappingFile extends DictionaryFile<CharMappingItem> {
56      /** Logger instance for this class. */
57      private static final Logger logger = LogManager.getLogger(CharMappingFile.class);
58  
59      /** Type identifier for character mapping dictionaries. */
60      private static final String MAPPING = "mapping";
61  
62      /** List of character mapping items loaded from the mapping file. */
63      List<CharMappingItem> mappingItemList;
64  
65      /**
66       * Constructs a new CharMappingFile instance.
67       *
68       * @param id the unique identifier for this mapping file
69       * @param path the file path to the character mapping dictionary
70       * @param timestamp the last modification timestamp of the file
71       */
72      public CharMappingFile(final String id, final String path, final Date timestamp) {
73          super(id, path, timestamp);
74      }
75  
76      /**
77       * Returns the type identifier for this dictionary file.
78       *
79       * @return the string "mapping" identifying this as a character mapping file
80       */
81      @Override
82      public String getType() {
83          return MAPPING;
84      }
85  
86      /**
87       * Returns the file path of this character mapping dictionary.
88       *
89       * @return the file path as a string
90       */
91      @Override
92      public String getPath() {
93          return path;
94      }
95  
96      /**
97       * Retrieves a character mapping item by its ID.
98       *
99       * @param id the unique identifier of the mapping item to retrieve
100      * @return an OptionalEntity containing the mapping item if found, empty otherwise
101      */
102     @Override
103     public OptionalEntity<CharMappingItem> get(final long id) {
104         if (mappingItemList == null) {
105             reload(null);
106         }
107 
108         for (final CharMappingItem mappingItem : mappingItemList) {
109             if (id == mappingItem.getId()) {
110                 return OptionalEntity.of(mappingItem);
111             }
112         }
113         return OptionalEntity.empty();
114     }
115 
116     /**
117      * Retrieves a paginated list of character mapping items.
118      *
119      * @param offset the starting index for pagination (0-based)
120      * @param size the maximum number of items to return
121      * @return a PagingList containing the requested subset of mapping items
122      */
123     @Override
124     public synchronized PagingList<CharMappingItem> selectList(final int offset, final int size) {
125         if (mappingItemList == null) {
126             reload(null);
127         }
128 
129         if (offset >= mappingItemList.size() || offset < 0) {
130             return new PagingList<>(Collections.<CharMappingItem> emptyList(), offset, size, mappingItemList.size());
131         }
132 
133         int toIndex = offset + size;
134         if (toIndex > mappingItemList.size()) {
135             toIndex = mappingItemList.size();
136         }
137 
138         return new PagingList<>(mappingItemList.subList(offset, toIndex), offset, size, mappingItemList.size());
139     }
140 
141     /**
142      * Inserts a new character mapping item into the dictionary file.
143      *
144      * @param item the character mapping item to insert
145      */
146     @Override
147     public synchronized void insert(final CharMappingItem item) {
148         try (MappingUpdater updater = new MappingUpdater(item)) {
149             reload(updater);
150         }
151     }
152 
153     /**
154      * Updates an existing character mapping item in the dictionary file.
155      *
156      * @param item the character mapping item to update
157      */
158     @Override
159     public synchronized void update(final CharMappingItem item) {
160         try (MappingUpdater updater = new MappingUpdater(item)) {
161             reload(updater);
162         }
163     }
164 
165     /**
166      * Deletes a character mapping item from the dictionary file.
167      *
168      * @param item the character mapping item to delete
169      */
170     @Override
171     public synchronized void delete(final CharMappingItem item) {
172         final CharMappingItem mappingItem = item;
173         mappingItem.setNewInputs(StringUtil.EMPTY_STRINGS);
174         mappingItem.setNewOutput(StringUtil.EMPTY);
175         try (MappingUpdater updater = new MappingUpdater(item)) {
176             reload(updater);
177         }
178     }
179 
180     /**
181      * Reloads the character mapping items from the dictionary file.
182      *
183      * @param updater the mapping updater to use for writing changes, or null for read-only reload
184      */
185     protected void reload(final MappingUpdater updater) {
186         try (CurlResponse curlResponse = dictionaryManager.getContentResponse(this)) {
187             reload(updater, curlResponse.getContentAsStream());
188         } catch (final IOException e) {
189             throw new DictionaryException("Failed to parse " + path, e);
190         }
191     }
192 
193     /**
194      * Reloads the character mapping items from the provided input stream.
195      * Parses mapping rules in the format: input1,input2,... => output
196      *
197      * @param updater the mapping updater to use for writing changes, or null for read-only reload
198      * @param in the input stream to read the mapping data from
199      */
200     protected void reload(final MappingUpdater updater, final InputStream in) {
201         final Pattern parsePattern = Pattern.compile("(.*?)\\s*+=>\\s*+(.*?)\\s*+$");
202         final List<CharMappingItem> itemList = new ArrayList<>();
203         try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, Constants.UTF_8))) {
204             long id = 0;
205             String line = null;
206             while ((line = reader.readLine()) != null) {
207                 // Remove comments
208                 final String replacedLine = line.replaceAll("#.*$", StringUtil.EMPTY).trim();
209 
210                 // Skip empty lines or comment lines
211                 if (replacedLine.length() == 0) {
212                     if (updater != null) {
213                         updater.write(line);
214                     }
215                     continue;
216                 }
217 
218                 String[] inputs;
219                 String output;
220 
221                 final Matcher m = parsePattern.matcher(replacedLine);
222 
223                 if (!m.find()) {
224                     logger.warn("Failed to parse mapping: line={}, path={}", line, path);
225                     if (updater != null) {
226                         updater.write("# " + line);
227                     }
228                     continue;
229                 }
230 
231                 inputs = m.group(1).trim().split(",");
232                 output = m.group(2).trim();
233 
234                 if (inputs == null || output == null || inputs.length == 0) {
235                     logger.warn("Failed to parse mapping: line={}, path={}", line, path);
236                     if (updater != null) {
237                         updater.write("# " + line);
238                     }
239                     continue;
240                 }
241 
242                 id++;
243                 final CharMappingItem item = new CharMappingItem(id, inputs, output);
244 
245                 if (updater != null) {
246                     final CharMappingItem newItem = updater.write(item);
247                     if (newItem != null) {
248                         itemList.add(newItem);
249                     } else {
250                         id--;
251                     }
252                 } else {
253                     itemList.add(item);
254                 }
255             }
256             if (updater != null) {
257                 final CharMappingItem item = updater.commit();
258                 if (item != null) {
259                     itemList.add(item);
260                 }
261             }
262             mappingItemList = itemList;
263         } catch (final IOException e) {
264             throw new DictionaryException("Failed to parse " + path, e);
265         }
266     }
267 
268     /**
269      * Returns the simple file name (without directory path) of this mapping file.
270      *
271      * @return the file name without the full path
272      */
273     public String getSimpleName() {
274         return new File(path).getName();
275     }
276 
277     /**
278      * Updates the entire mapping file content from the provided input stream.
279      *
280      * @param in the input stream containing the new mapping file content
281      * @throws IOException if an I/O error occurs during the update
282      */
283     public synchronized void update(final InputStream in) throws IOException {
284         try (MappingUpdater updater = new MappingUpdater(null)) {
285             reload(updater, in);
286         }
287     }
288 
289     /**
290      * Returns a string representation of this character mapping file.
291      *
292      * @return a string containing the path, mapping items, and ID of this file
293      */
294     @Override
295     public String toString() {
296         return "MappingFile [path=" + path + ", mappingItemList=" + mappingItemList + ", id=" + id + "]";
297     }
298 
299     /**
300      * Inner class for handling updates to the character mapping file.
301      * This class manages the temporary file creation, writing operations,
302      * and atomic updates to ensure data consistency during modifications.
303      */
304     protected class MappingUpdater implements Closeable {
305 
306         /** Flag indicating whether changes should be committed to the file. */
307         protected boolean isCommit = false;
308 
309         /** Temporary file used for writing updates before committing. */
310         protected File newFile;
311 
312         /** Writer for outputting content to the temporary file. */
313         protected Writer writer;
314 
315         /** The mapping item being updated, or null for read-only operations. */
316         protected CharMappingItem item;
317 
318         /**
319          * Constructs a new MappingUpdater for handling file updates.
320          *
321          * @param newItem the character mapping item to update, or null for read-only operations
322          */
323         protected MappingUpdater(final CharMappingItem newItem) {
324             FileOutputStream fos = null;
325             try {
326                 newFile = ComponentUtil.getSystemHelper().createTempFile(MAPPING, ".txt");
327                 fos = new FileOutputStream(newFile);
328                 writer = new BufferedWriter(new OutputStreamWriter(fos, Constants.UTF_8));
329                 fos = null; // Successfully wrapped, no need to close explicitly
330             } catch (final Exception e) {
331                 if (fos != null) {
332                     try {
333                         fos.close();
334                     } catch (final IOException ioe) {
335                         // Ignore close exception
336                     }
337                 }
338                 if (newFile != null) {
339                     newFile.delete();
340                 }
341                 throw new DictionaryException("Failed to write a userDict file.", e);
342             }
343             item = newItem;
344         }
345 
346         /**
347          * Writes a character mapping item to the temporary file.
348          *
349          * @param oldItem the existing mapping item to process
350          * @return the mapping item that was written, or null if the item was deleted
351          */
352         public CharMappingItem write(final CharMappingItem oldItem) {
353             try {
354                 if (item == null || item.getId() != oldItem.getId() || !item.isUpdated()) {
355                     writer.write(oldItem.toLineString());
356                     writer.write(Constants.LINE_SEPARATOR);
357                     return oldItem;
358                 }
359                 if (!item.equals(oldItem)) {
360                     throw new DictionaryException("Mapping file was updated: old=" + oldItem + " : new=" + item);
361                 }
362                 try {
363                     if (!item.isDeleted()) {
364                         // update
365                         writer.write(item.toLineString());
366                         writer.write(Constants.LINE_SEPARATOR);
367                         return new CharMappingItem(item.getId(), item.getNewInputs(), item.getNewOutput());
368                     }
369                     return null;
370                 } finally {
371                     item.setNewInputs(null);
372                     item.setNewOutput(null);
373                 }
374             } catch (final IOException e) {
375                 throw new DictionaryException("Failed to write: " + oldItem + " -> " + item, e);
376             }
377         }
378 
379         /**
380          * Writes a raw line of text to the temporary file.
381          *
382          * @param line the line of text to write
383          */
384         public void write(final String line) {
385             try {
386                 writer.write(line);
387                 writer.write(Constants.LINE_SEPARATOR);
388             } catch (final IOException e) {
389                 throw new DictionaryException("Failed to write: " + line, e);
390             }
391         }
392 
393         /**
394          * Commits any pending changes and marks the updater for final write.
395          *
396          * @return the committed mapping item, or null if no item was pending
397          */
398         public CharMappingItem commit() {
399             isCommit = true;
400             if (item != null && item.isUpdated()) {
401                 try {
402                     writer.write(item.toLineString());
403                     writer.write(Constants.LINE_SEPARATOR);
404                     return item;
405                 } catch (final IOException e) {
406                     throw new DictionaryException("Failed to write: " + item, e);
407                 }
408             }
409             return null;
410         }
411 
412         /**
413          * Closes the updater and finalizes the file update operation.
414          * If changes were committed, the temporary file replaces the original.
415          * Otherwise, the temporary file is deleted.
416          */
417         @Override
418         public void close() {
419             try {
420                 writer.flush();
421             } catch (final IOException e) {
422                 // ignore
423             }
424             CloseableUtil.closeQuietly(writer);
425 
426             if (isCommit) {
427                 try {
428                     dictionaryManager.store(CharMappingFile.this, newFile);
429                 } finally {
430                     newFile.delete();
431                 }
432             } else {
433                 newFile.delete();
434             }
435         }
436     }
437 
438 }