Not logged in.  Login/Logout/Register | List snippets | | Create snippet | Upload image | Upload data

630
LINES

< > BotCompany Repo | #1026090 // EditDistance (Ukkonen-optimized Levenshtein)

JavaX fragment (include) [tags: use-pretranspiled]

Libraryless. Click here for Pure Java version (3337L/20K).

1  
/*******************************************************************************
2  
 * Copyright (c) 2010-2019 Haifeng Li
3  
 *
4  
 * Smile is free software: you can redistribute it and/or modify
5  
 * it under the terms of the GNU Lesser General Public License as
6  
 * published by the Free Software Foundation, either version 3 of
7  
 * the License, or (at your option) any later version.
8  
 *
9  
 * Smile is distributed in the hope that it will be useful,
10  
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12  
 * GNU Lesser General Public License for more details.
13  
 *
14  
 * You should have received a copy of the GNU Lesser General Public License
15  
 * along with Smile.  If not, see <https://www.gnu.org/licenses/>.
16  
 *******************************************************************************/
17  
18  
/**
19  
 * The Edit distance between two strings is a metric for measuring the amount
20  
 * of difference between two sequences. The Levenshtein distance between two
21  
 * strings is given by the minimum number of operations needed to transform one
22  
 * string into the other, where an operation is an insertion, deletion, or
23  
 * substitution of a single character. A generalization of the Levenshtein
24  
 * distance (Damerau-Levenshtein distance) allows the transposition of two
25  
 * characters as an operation.
26  
 * <p>
27  
 * Given two strings x and y of length m and n (suppose n &ge; m), this
28  
 * implementation takes O(ne) time and O(mn) space by an extended Ukkonen's
29  
 * algorithm in case of unit cost, where e is the edit distance between x and y.
30  
 * Thus this algorithm is output sensitive. The smaller the distance, the faster
31  
 * it runs.
32  
 * <p>
33  
 * For weighted cost, this implements the regular dynamic programming algorithm,
34  
 * which takes O(mn) time and O(m) space.
35  
 *
36  
 * @author Haifeng Li
37  
 */
38  
final sclass EditDistance /*implements Metric<String>*/ {
39  
  /**
40  
   * Weight matrix for weighted Levenshtein distance.
41  
   */
42  
  private IIntArray2D weight;
43  
44  
  /**
45  
   * Radius of Sakoe-Chiba band
46  
   */
47  
  private double r = -1;
48  
49  
  /**
50  
   * Calculate Damerau or basic Levenshitein distance.
51  
   */
52  
  private boolean damerau = false;
53  
54  
  /**
55  
   * Cost matrix. Because Java automatically initialize arrays, it
56  
   * takes O(mn) to declare this cost matrix every time before
57  
   * calculate edit distance. But the whole point of Berghel & Roach
58  
   * algorithm is to calculate fewer cells than O(mn). Therefore,
59  
   * we create this cost matrix here. Therefore, the methods using
60  
   * this cost matrix is not multi-thread safe.
61  
   */
62  
  private IIntArray2D FKP;
63  
64  
  /**
65  
   * The lambda to calculate FKP array.
66  
   */
67  
  private BRF brf;
68  
69  
70  
  /**
71  
   * Constructor. Multi-thread safe Levenshtein distance.
72  
   */
73  
  public EditDistance() {
74  
      this(false);
75  
  }
76  
77  
  /**
78  
   * Constructor. Multi-thread safe Damerau-Levenshtein distance.
79  
   * @param damerau if true, calculate Damerau-Levenshtein distance
80  
   *                instead of plain Levenshtein distance.
81  
   */
82  
  public EditDistance(boolean damerau) {
83  
      this.damerau = damerau;
84  
  }
85  
86  
  /**
87  
   * Constructor. Highly efficient Levenshtein distance but not multi-thread safe.
88  
   * @param maxStringLength the maximum length of strings that will be
89  
   * feed to this algorithm.
90  
   */
91  
  public EditDistance(int maxStringLength) {
92  
      this(maxStringLength, false);
93  
  }
94  
95  
  /**
96  
   * Constructor. Highly efficient Damerau-Levenshtein distance but not multi-thread safe.
97  
   * @param maxStringLength the maximum length of strings that will be
98  
   *                        feed to this algorithm.
99  
   * @param damerau if true, calculate Damerau-Levenshtein distance
100  
   *                instead of plain Levenshtein distance.
101  
   */
102  
  public EditDistance(int maxStringLength, boolean damerau) {
103  
      this.damerau = damerau;
104  
      FKP = new IntArray2D(2*maxStringLength+1, maxStringLength+2);
105  
      brf = damerau ? new DamerauBRF() : new LevenshteinBRF();
106  
  }
107  
108  
  /**
109  
   * Constructor. Weighted Levenshtein distance without path
110  
   * constraints. Only insertion, deletion, and substitution operations are
111  
   * supported.
112  
   */
113  
  public EditDistance(int[][] weight) {
114  
      this(weight, -1);
115  
  }
116  
117  
  /**
118  
   * Constructor. Weighted Levenshtein distance with
119  
   * Sakoe-Chiba band, which improve computational cost. Only
120  
   * insertion, deletion, and substitution operations are supported.
121  
   * @param radius the window width of Sakoe-Chiba band in terms of percentage of sequence length.
122  
   */
123  
  public EditDistance(int[][] weight, double radius) {
124  
      this.weight = new IntArray2D(weight);
125  
      this.r = radius;
126  
  }
127  
128  
  @Override
129  
  public String toString() {
130  
      if (damerau) {
131  
          if (weight != null)
132  
              return String.format("Damerau-Levenshtein Distance(radius = %d, weight = %s)", r, weight.toString());
133  
          else
134  
              return "Damerau-Levenshtein Distance";
135  
      } else {
136  
          if (weight != null)
137  
              return String.format("Levenshtein Distance(radius = %d, weight = %s)", r, weight.toString());
138  
          else
139  
              return  "Levenshtein Distance";
140  
      }
141  
  }
142  
143  
  /**
144  
   * Edit distance between two strings. O(mn) time and O(n) space for weighted
145  
   * edit distance. O(ne) time and O(mn) space for unit cost edit distance.
146  
   * For weighted edit distance, this method is multi-thread safe. However,
147  
   * it is NOT multi-thread safe for unit cost edit distance.
148  
   */
149  
  public double d(String x, String y) {
150  
      if (weight != null)
151  
          return weightedEdit(x, y);
152  
      else if (FKP == null || x.length() == 1 || y.length() == 1)
153  
          return damerau ? damerau(x, y) : levenshtein(x, y);
154  
      else
155  
          return br(x, y);
156  
  }
157  
158  
  /**
159  
   * Edit distance between two strings. O(mn) time and O(n) space for weighted
160  
   * edit distance. O(ne) time and O(mn) space for unit cost edit distance.
161  
   * For weighted edit distance, this method is multi-thread safe. However,
162  
   * it is NOT multi-thread safe for unit cost edit distance.
163  
   */
164  
  public double d(char[] x, char[] y) {
165  
      if (weight != null)
166  
          return weightedEdit(x, y);
167  
      else if (FKP == null || x.length == 1 || y.length == 1)
168  
          return damerau ? damerau(x, y) : levenshtein(x, y);
169  
      else
170  
          return br(x, y);
171  
  }
172  
173  
  /**
174  
   * Weighted edit distance.
175  
   */
176  
  private double weightedEdit(char[] x, char[] y) {
177  
      // switch parameters to use the shorter one as y to save space.
178  
      if (x.length < y.length) {
179  
          char[] swap = x;
180  
          x = y;
181  
          y = swap;
182  
      }
183  
184  
      int radius = (int) Math.round(r * Math.max(x.length, y.length));
185  
186  
      double[][] d = new double[2][y.length + 1];
187  
188  
      d[0][0] = 0.0;
189  
      for (int j = 1; j <= y.length; j++) {
190  
          d[0][j] = d[0][j - 1] + weight.get(0, y[j]);
191  
      }
192  
193  
      for (int i = 1; i <= x.length; i++) {
194  
          d[1][0] = d[0][0] + weight.get(x[i], 0);
195  
196  
          int start = 1;
197  
          int end = y.length;
198  
199  
          if (radius > 0) {
200  
              start = i - radius;
201  
              if (start > 1)
202  
                  d[1][start - 1] = Double.POSITIVE_INFINITY;
203  
              else
204  
                  start = 1;
205  
206  
              end = i + radius;
207  
              if (end < y.length)
208  
                  d[1][end+1] = Double.POSITIVE_INFINITY;
209  
              else
210  
                  end = y.length;
211  
          }
212  
213  
          for (int j = start; j <= end; j++) {
214  
              double cost = weight.get(x[i - 1], y[j - 1]);
215  
              d[1][j] = min3(
216  
                      d[0][j] + weight.get(x[i - 1], 0), // deletion
217  
                      d[1][j - 1] + weight.get(0, y[j - 1]), // insertion
218  
                      d[0][j - 1] + cost); // substitution
219  
          }
220  
221  
          double[] swap = d[0];
222  
          d[0] = d[1];
223  
          d[1] = swap;
224  
      }
225  
226  
      return d[0][y.length];
227  
  }
228  
229  
  /**
230  
   * Weighted edit distance.
231  
   */
232  
  private double weightedEdit(String x, String y) {
233  
      // switch parameters to use the shorter one as y to save space.
234  
      if (x.length() < y.length()) {
235  
          String swap = x;
236  
          x = y;
237  
          y = swap;
238  
      }
239  
240  
      int radius = (int) Math.round(r * Math.max(x.length(), y.length()));
241  
242  
      double[][] d = new double[2][y.length() + 1];
243  
244  
      d[0][0] = 0.0;
245  
      for (int j = 1; j <= y.length(); j++) {
246  
          d[0][j] = d[0][j - 1] + weight.get(0, y.charAt(j));
247  
      }
248  
249  
      for (int i = 1; i <= x.length(); i++) {
250  
          d[1][0] = d[0][0] + weight.get(x.charAt(i), 0);
251  
252  
          int start = 1;
253  
          int end = y.length();
254  
255  
          if (radius > 0) {
256  
              start = i - radius;
257  
              if (start > 1)
258  
                  d[1][start - 1] = Double.POSITIVE_INFINITY;
259  
              else
260  
                  start = 1;
261  
262  
              end = i + radius;
263  
              if (end < y.length())
264  
                  d[1][end+1] = Double.POSITIVE_INFINITY;
265  
              else
266  
                  end = y.length();
267  
          }
268  
269  
          for (int j = start; j <= end; j++) {
270  
              double cost = weight.get(x.charAt(i - 1), y.charAt(j - 1));
271  
              d[1][j] = min3(
272  
                      d[0][j] + weight.get(x.charAt(i - 1), 0), // deletion
273  
                      d[1][j - 1] + weight.get(0, y.charAt(j - 1)), // insertion
274  
                      d[0][j - 1] + cost); // substitution
275  
          }
276  
277  
          double[] swap = d[0];
278  
          d[0] = d[1];
279  
          d[1] = swap;
280  
      }
281  
282  
      return d[0][y.length()];
283  
  }
284  
285  
  /**
286  
   * Berghel & Roach's extended Ukkonen's algorithm.
287  
   */
288  
  private int br(char[] x, char[] y) {
289  
      if (x.length > y.length) {
290  
          char[] swap = x;
291  
          x = y;
292  
          y = swap;
293  
      }
294  
295  
      final int m = x.length;
296  
      final int n = y.length;
297  
298  
      int ZERO_K = n;
299  
300  
      if (n+2 > FKP.ncols())
301  
          FKP = new IntArray2D(2*n+1, n+2);
302  
303  
      for (int k = -ZERO_K; k < 0; k++) {
304  
          int p = -k - 1;
305  
          FKP.set(k + ZERO_K, p + 1, Math.abs(k) - 1);
306  
          FKP.set(k + ZERO_K, p, Integer.MIN_VALUE);
307  
      }
308  
309  
      FKP.set(ZERO_K, 0, -1);
310  
311  
      for (int k = 1; k <= ZERO_K; k++) {
312  
          int p = k - 1;
313  
          FKP.set(k + ZERO_K, p + 1, -1);
314  
          FKP.set(k + ZERO_K, p, Integer.MIN_VALUE);
315  
      }
316  
317  
      int p = n - m - 1;
318  
319  
      do {
320  
          p++;
321  
322  
          for (int i = (p - (n-m))/2; i >= 1; i--) {
323  
              brf.f(x, y, FKP, ZERO_K, n-m+i, p-i);
324  
          }
325  
326  
          for (int i = (n-m+p)/2; i >= 1; i--) {
327  
              brf.f(x, y, FKP, ZERO_K, n-m-i, p-i);
328  
          }
329  
330  
          brf.f(x, y, FKP, ZERO_K, n - m, p);
331  
      } while (FKP.get((n - m) + ZERO_K, p) != m);
332  
333  
      return p - 1;
334  
  }
335  
336  
  /**
337  
   * Berghel & Roach's extended Ukkonen's algorithm.
338  
   */
339  
  private int br(String x, String y) {
340  
      if (x.length() > y.length()) {
341  
          String swap = x;
342  
          x = y;
343  
          y = swap;
344  
      }
345  
346  
      final int m = x.length();
347  
      final int n = y.length();
348  
349  
      int ZERO_K = n;
350  
351  
      if (n+3 > FKP.ncols())
352  
          FKP = new IntArray2D(2*n+1, n+3);
353  
354  
      for (int k = -ZERO_K; k < 0; k++) {
355  
          int p = -k - 1;
356  
          FKP.set(k + ZERO_K, p + 1, Math.abs(k) - 1);
357  
          FKP.set(k + ZERO_K, p, Integer.MIN_VALUE);
358  
      }
359  
360  
      FKP.set(ZERO_K, 0, -1);
361  
362  
      for (int k = 1; k <= ZERO_K; k++) {
363  
          int p = k - 1;
364  
          FKP.set(k + ZERO_K, p + 1, -1);
365  
          FKP.set(k + ZERO_K, p, Integer.MIN_VALUE);
366  
      }
367  
368  
      int p = n - m - 1;
369  
370  
      do {
371  
          p++;
372  
373  
          for (int i = (p - (n-m))/2; i >= 1; i--) {
374  
              brf.f(x, y, FKP, ZERO_K, n-m+i, p-i);
375  
          }
376  
377  
          for (int i = (n-m+p)/2; i >= 1; i--) {
378  
              brf.f(x, y, FKP, ZERO_K, n-m-i, p-i);
379  
          }
380  
381  
          brf.f(x, y, FKP, ZERO_K, n - m, p);
382  
      } while (FKP.get((n - m) + ZERO_K, p) != m);
383  
384  
      return p - 1;
385  
  }
386  
387  
  private static class LevenshteinBRF implements BRF {
388  
      @Override
389  
      public void f(char[] x, char[] y, IIntArray2D FKP, int ZERO_K, int k, int p) {
390  
          int t = max3(FKP.get(k + ZERO_K, p) + 1, FKP.get(k - 1 + ZERO_K, p), FKP.get(k + 1 + ZERO_K, p) + 1);
391  
          int mnk = Math.min(x.length, y.length - k);
392  
393  
          while (t < mnk && x[t] == y[t + k]) {
394  
              t++;
395  
          }
396  
397  
          FKP.set(k + ZERO_K, p + 1, t);
398  
      }
399  
400  
      @Override
401  
      public void f(String x, String y, IIntArray2D FKP, int ZERO_K, int k, int p) {
402  
          int t = max3(FKP.get(k + ZERO_K, p) + 1, FKP.get(k - 1 + ZERO_K, p), FKP.get(k + 1 + ZERO_K, p) + 1);
403  
          int mnk = Math.min(x.length(), y.length() - k);
404  
405  
          while (t < mnk && x.charAt(t) == y.charAt(t + k)) {
406  
              t++;
407  
          }
408  
409  
          FKP.set(k + ZERO_K, p + 1, t);
410  
      }
411  
  }
412  
413  
  /**
414  
   * Calculate FKP arrays in BR's algorithm with support of transposition operation.
415  
   */
416  
  private static class DamerauBRF implements BRF {
417  
      @Override
418  
      public void f(char[] x, char[] y, IIntArray2D FKP, int ZERO_K, int k, int p) {
419  
          int t = FKP.get(k + ZERO_K, p) + 1;
420  
          int mnk = Math.min(x.length, y.length - k);
421  
422  
          if (t >= 1 && k + t >= 1 && t < mnk) {
423  
              if (x[t - 1] == y[k + t] && x[t] == y[k + t - 1]) {
424  
                  t++;
425  
              }
426  
          }
427  
428  
          t = max3(FKP.get(k - 1 + ZERO_K, p), FKP.get(k + 1 + ZERO_K, p) + 1, t);
429  
430  
          while (t < mnk && x[t] == y[t + k]) {
431  
              t++;
432  
          }
433  
434  
          FKP.set(k + ZERO_K, p + 1, t);
435  
      }
436  
437  
      @Override
438  
      public void f(String x, String y, IIntArray2D FKP, int ZERO_K, int k, int p) {
439  
          int t = FKP.get(k + ZERO_K, p) + 1;
440  
          int mnk = Math.min(x.length(), y.length() - k);
441  
442  
          if (t >= 1 && k + t >= 1 && t < mnk) {
443  
              if (x.charAt(t - 1) == y.charAt(k + t) && x.charAt(t) == y.charAt(k + t - 1)) {
444  
                  t++;
445  
              }
446  
          }
447  
448  
          t = max3(FKP.get(k - 1 + ZERO_K, p), FKP.get(k + 1 + ZERO_K, p) + 1, t);
449  
450  
          while (t < mnk && x.charAt(t) == y.charAt(t + k)) {
451  
              t++;
452  
          }
453  
454  
          FKP.set(k + ZERO_K, p + 1, t);
455  
      }
456  
  }
457  
  
458  
  static interface BRF {
459  
      /**
460  
       * Calculate FKP arrays in BR's algorithm.
461  
       */
462  
      void f(char[] x, char[] y, IIntArray2D FKP, int ZERO_K, int k, int p);
463  
      /**
464  
       * Calculate FKP arrays in BR's algorithm.
465  
       */
466  
      void f(String x, String y, IIntArray2D FKP, int ZERO_K, int k, int p);
467  
  }
468  
469  
    /**
470  
     * Levenshtein distance between two strings allows insertion, deletion,
471  
     * or substitution of characters. O(mn) time and O(n) space.
472  
     * Multi-thread safe.
473  
     */
474  
    public static int levenshtein(String x, String y) {
475  
        // switch parameters to use the shorter one as y to save space.
476  
        if (x.length() < y.length()) {
477  
            String swap = x;
478  
            x = y;
479  
            y = swap;
480  
        }
481  
482  
        int[][] d = new int[2][y.length() + 1];
483  
484  
        for (int j = 0; j <= y.length(); j++) {
485  
            d[0][j] = j;
486  
        }
487  
488  
        for (int i = 1; i <= x.length(); i++) {
489  
            d[1][0] = i;
490  
491  
            for (int j = 1; j <= y.length(); j++) {
492  
                int cost = x.charAt(i - 1) == y.charAt(j - 1) ? 0 : 1;
493  
                d[1][j] = min3(
494  
                        d[0][j] + 1, // deletion
495  
                        d[1][j - 1] + 1, // insertion
496  
                        d[0][j - 1] + cost); // substitution
497  
            }
498  
            int[] swap = d[0];
499  
            d[0] = d[1];
500  
            d[1] = swap;
501  
        }
502  
503  
        return d[0][y.length()];
504  
    }
505  
506  
    /**
507  
     * Levenshtein distance between two strings allows insertion, deletion,
508  
     * or substitution of characters. O(mn) time and O(n) space.
509  
     * Multi-thread safe.
510  
     */
511  
    public static int levenshtein(char[] x, char[] y) {
512  
        // switch parameters to use the shorter one as y to save space.
513  
        if (x.length < y.length) {
514  
            char[] swap = x;
515  
            x = y;
516  
            y = swap;
517  
        }
518  
519  
        int[][] d = new int[2][y.length + 1];
520  
521  
        for (int j = 0; j <= y.length; j++) {
522  
            d[0][j] = j;
523  
        }
524  
525  
        for (int i = 1; i <= x.length; i++) {
526  
            d[1][0] = i;
527  
528  
            for (int j = 1; j <= y.length; j++) {
529  
                int cost = x[i - 1] == y[j - 1] ? 0 : 1;
530  
                d[1][j] = min3(
531  
                        d[0][j] + 1, // deletion
532  
                        d[1][j - 1] + 1, // insertion
533  
                        d[0][j - 1] + cost); // substitution
534  
            }
535  
            int[] swap = d[0];
536  
            d[0] = d[1];
537  
            d[1] = swap;
538  
        }
539  
540  
        return d[0][y.length];
541  
    }
542  
543  
    /**
544  
     * Damerau-Levenshtein distance between two strings allows insertion,
545  
     * deletion, substitution, or transposition of characters.
546  
     * O(mn) time and O(n) space. Multi-thread safe.
547  
     */
548  
    public static int damerau(String x, String y) {
549  
        // switch parameters to use the shorter one as y to save space.
550  
        if (x.length() < y.length()) {
551  
            String swap = x;
552  
            x = y;
553  
            y = swap;
554  
        }
555  
556  
        int[][] d = new int[3][y.length() + 1];
557  
558  
        for (int j = 0; j <= y.length(); j++) {
559  
            d[1][j] = j;
560  
        }
561  
562  
        for (int i = 1; i <= x.length(); i++) {
563  
            d[2][0] = i;
564  
565  
            for (int j = 1; j <= y.length(); j++) {
566  
                int cost = x.charAt(i-1) == y.charAt(j-1) ? 0 : 1;
567  
                d[2][j] = min3(
568  
                        d[1][j] + 1,       // deletion
569  
                        d[2][j-1] + 1,       // insertion
570  
                        d[1][j-1] + cost); // substitution
571  
572  
                if (i > 1 && j > 1) {
573  
                    if (x.charAt(i-1) == y.charAt(j-2) && x.charAt(i-2) == y.charAt(j-1))
574  
                        d[2][j] = Math.min(d[2][j], d[0][j-2] + cost);   // damerau
575  
                }
576  
            }
577  
578  
            int[] swap = d[0];
579  
            d[0] = d[1];
580  
            d[1] = d[2];
581  
            d[2] = swap;
582  
        }
583  
584  
        return d[1][y.length()];
585  
    }
586  
587  
    /**
588  
     * Damerau-Levenshtein distance between two strings allows insertion,
589  
     * deletion, substitution, or transposition of characters.
590  
     * O(mn) time and O(n) space. Multi-thread safe.
591  
     */
592  
    public static int damerau(char[] x, char[] y) {
593  
        // switch parameters to use the shorter one as y to save space.
594  
        if (x.length < y.length) {
595  
            char[] swap = x;
596  
            x = y;
597  
            y = swap;
598  
        }
599  
600  
        int[][] d = new int[3][y.length + 1];
601  
602  
        for (int j = 0; j <= y.length; j++) {
603  
            d[1][j] = j;
604  
        }
605  
606  
        for (int i = 1; i <= x.length; i++) {
607  
            d[2][0] = i;
608  
609  
            for (int j = 1; j <= y.length; j++) {
610  
                int cost = x[i-1] == y[j-1] ? 0 : 1;
611  
                d[2][j] = min3(
612  
                        d[1][j] + 1,       // deletion
613  
                        d[2][j-1] + 1,       // insertion
614  
                        d[1][j-1] + cost); // substitution
615  
616  
                if (i > 1 && j > 1) {
617  
                    if (x[i-1] == y[j-2] && x[i-2] == y[j-1])
618  
                        d[2][j] = Math.min(d[2][j], d[0][j-2] + cost);   // damerau
619  
                }
620  
            }
621  
622  
            int[] swap = d[0];
623  
            d[0] = d[1];
624  
            d[1] = d[2];
625  
            d[2] = swap;
626  
        }
627  
628  
        return d[1][y.length];
629  
    }
630  
}

download  show line numbers  debug dex  old transpilations   

Travelled to 6 computer(s): bhatertpkbcr, mqqgnosmbjvj, pyentgdyhuwx, pzhvpgtvlbxg, tvejysmllsmz, vouqrxazstgt

No comments. add comment

Snippet ID: #1026090
Snippet name: EditDistance (Ukkonen-optimized Levenshtein)
Eternal ID of this version: #1026090/12
Text MD5: 8de66b6e60486cf7a6446e083e8525bd
Transpilation MD5: f930147f105ecf72101cc5439315c298
Author: stefan
Category: javax
Type: JavaX fragment (include)
Public (visible to everyone): Yes
Archived (hidden from active list): No
Created/modified: 2019-11-27 03:02:26
Source code size: 19786 bytes / 630 lines
Pitched / IR pitched: No / No
Views / Downloads: 265 / 654
Version history: 11 change(s)
Referenced in: [show references]