/*
* Copyright 2011 Luke Usherwood.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 2.1 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package net.bettyluke.tracinstant.download;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.function.BiPredicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import net.bettyluke.tracinstant.data.AuthenticatedHttpRequester;
import net.bettyluke.tracinstant.data.Ticket;
import net.bettyluke.tracinstant.download.Downloadable.FileDownloadable;
import net.bettyluke.tracinstant.download.Downloadable.TracDownloadable;
import net.bettyluke.tracinstant.prefs.SiteSettings;
import net.bettyluke.tracinstant.prefs.TracInstantProperties;
public class AttachmentCounter {
private static final Pattern NAME_MATCHER = Pattern.compile("^(\\d+).*");
private static final int MAX_SEARCH_DEPTH = 4;
private static final BiPredicate<Path, BasicFileAttributes> IS_FILE_PREDICATE =
(p, attrs) -> !attrs.isDirectory() && !attrs.isSymbolicLink();
private static final Pattern ATTACHMENT_LINK =
Pattern.compile("href=\\\"(/attachment/ticket/[^\\\"]+)\\\"");
/**
* All matching directories found under the top attachment directory. This is cached after each
* incremental slurp to reduce a bit of work during UI events, like table selection changes.
* (i.e. don't bother searching in directories that don't exist.)
*/
private static Map<Integer, Path> s_AttachmentSubDirs =
Collections.synchronizedMap(new TreeMap<Integer, Path>());
/**
* The most recently executed AttachmentCounter. Thread-safety: always accessed
* by the EDT.
*/
private static AttachmentCounter s_CurrentJob = null;
/** Counts attachments found attached to ticket(s). */
private final TracCounter m_TicketCounter = new TracCounter();
/** Counts attachments found under the additional attachment directory. */
private final FileCounter m_DirectoryCounter = new FileCounter();
private final Ticket[] m_Tickets;
private final CountCallback m_Callback;
private boolean m_FoundFiles = false;
private boolean m_FoundAttachments = false;
private boolean m_Cancelled = false;
private AttachmentCounter(Ticket[] tickets, CountCallback callback) {
m_Tickets = tickets;
m_Callback = callback;
}
public static interface CountCallback {
void restart();
void downloadsFound(List<? extends Downloadable> attachments);
void done();
}
/**
* Asynchronously scan the attachments folder for sub-directories matching
* {@link #NAME_MATCHER}. Downloads will not be available until this scanning is
* performed and is complete.
*
* @return An object that can be used to determine the success of scanning, or
* cancel the background task.
*/
public static Future<Map<Integer, Path>> scanAttachmentsFolderAsynchronously(String topFolder) {
SwingWorker<Map<Integer, Path>, Void> scanner = new SwingWorker<Map<Integer,Path>, Void>() {
@Override
protected Map<Integer, Path> doInBackground() throws Exception {
return scanAttachmentsFolder();
}
private Map<Integer, Path> scanAttachmentsFolder() {
long t0 = System.nanoTime();
if (topFolder.trim().isEmpty()) {
return Collections.emptyMap();
}
Map<Integer, Path> results =
Collections.synchronizedMap(new TreeMap<Integer, Path>());
Path bugDir = Paths.get(topFolder);
assert bugDir.isAbsolute();
// Unfortunately there doesn't seem to be an easy way to interrupt this
// potentially-long command. (It is slow on certain network drives.)
String[] listing = bugDir.toFile().list();
if (listing == null) {
System.err.println("Failed to list directory: " + bugDir);
return results;
}
for (String name : listing) {
Matcher m = NAME_MATCHER.matcher(name);
if (m.matches()) {
String id = m.group(1);
Path subDir = bugDir.resolve(name);
try {
results.put(Integer.valueOf(id), subDir);
} catch (NumberFormatException ex) {
// Ignore. Perhaps a 'big number'.
}
}
}
long t1 = System.nanoTime();
System.out.format("Time to scan the AttachmentsFolder: %.2f ms\n",
(t1 - t0) / 1000000f);
return results;
}
@Override
protected void done() {
if (isCancelled()) {
return;
}
try {
// Not exactly atomic... oh well.
s_AttachmentSubDirs.clear();
s_AttachmentSubDirs.putAll(get());
} catch (InterruptedException | ExecutionException e) {
}
}
};
scanner.execute();
return scanner;
}
public static void restartCounting(Ticket[] tickets, CountCallback callback) {
assert SwingUtilities.isEventDispatchThread();
if (s_CurrentJob != null) {
s_CurrentJob.cancel();
}
callback.restart();
s_CurrentJob = new AttachmentCounter(tickets, callback);
s_CurrentJob.m_DirectoryCounter.execute();
s_CurrentJob.m_TicketCounter.execute();
}
private void cancel() {
m_Cancelled = true;
m_DirectoryCounter.cancel(true);
m_TicketCounter.cancel(true);
}
/** Counts attachments found under the additional attachment directory. */
public class FileCounter extends SwingWorker<Void, FileDownloadable> {
@Override
protected Void doInBackground() throws InterruptedException {
for (Ticket ticket : m_Tickets) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
Path ticketDir = s_AttachmentSubDirs.get(ticket.getNumber());
if (ticketDir == null) {
continue;
}
try (Stream<Path> paths = streamRelativeFiles(ticketDir)) {
FileDownloadable[] downloadable = paths
.map(p -> new FileDownloadable(ticket.getNumber(), ticketDir, p))
.toArray(FileDownloadable[]::new);
publish(downloadable);
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
private Stream<Path> streamRelativeFiles(Path bugDir) throws IOException {
// No idea why attribs.isRegularFile() returns false on Windows Network (UNC) paths...
return Files
.find(bugDir, MAX_SEARCH_DEPTH, IS_FILE_PREDICATE)
.map(p -> bugDir.relativize(p));
}
@Override
protected void process(List<FileDownloadable> chunks) {
if (!m_Cancelled) {
m_Callback.downloadsFound(chunks);
}
}
@Override
protected void done() {
m_FoundFiles = true;
checkAllDone();
}
}
/** Counts attachments found attached to Trac ticket(s). */
public class TracCounter extends SwingWorker<Void, TracDownloadable> {
@Override
protected Void doInBackground() throws InterruptedException {
for (Ticket ticket : m_Tickets) {
if (Thread.interrupted()) {
throw new InterruptedException();
}
try {
int ticketNum = ticket.getNumber();
URL listingURL = createAttachmentPageURL(ticketNum);
scanTracAttachementPage(ticketNum, listingURL);
} catch (MalformedURLException ex) {
ex.printStackTrace();
} catch (IOException ex) {
// TODO: Error reporting
ex.printStackTrace();
}
}
return null;
}
private URL createAttachmentPageURL(int number) throws MalformedURLException {
// Trailing '/' required for Trac 0.12 (wasn't needed for 0.10)
return new URL(TracInstantProperties.getURL() + "/attachment/ticket/" + number + '/');
}
private void scanTracAttachementPage(int ticketNum, URL url) throws IOException {
try (InputStream in = getAuthenticatedInputStream(url);
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
Matcher m = ATTACHMENT_LINK.matcher(line);
if (m.find()) {
publish(new TracDownloadable(ticketNum, m.group(1), 0));
}
}
}
}
private InputStream getAuthenticatedInputStream(URL url) throws IOException {
return AuthenticatedHttpRequester.getInputStream(SiteSettings.getInstance(), url);
}
@Override
protected void process(List<TracDownloadable> chunks) {
if (!m_Cancelled) {
m_Callback.downloadsFound(chunks);
}
}
@Override
protected void done() {
m_FoundAttachments = true;
checkAllDone();
}
}
void checkAllDone() {
assert SwingUtilities.isEventDispatchThread();
if (!m_Cancelled && m_FoundFiles && m_FoundAttachments) {
m_Callback.done();
}
}
}