diff --git a/core/src/main/groovy/com/muwire/core/Core.groovy b/core/src/main/groovy/com/muwire/core/Core.groovy index e60a26cd..f225deec 100644 --- a/core/src/main/groovy/com/muwire/core/Core.groovy +++ b/core/src/main/groovy/com/muwire/core/Core.groovy @@ -32,6 +32,16 @@ import com.muwire.core.filecert.CertificateManager import com.muwire.core.filecert.UICreateCertificateEvent import com.muwire.core.filecert.UIFetchCertificatesEvent import com.muwire.core.filecert.UIImportCertificateEvent +import com.muwire.core.filefeeds.FeedClient +import com.muwire.core.filefeeds.FeedFetchEvent +import com.muwire.core.filefeeds.FeedItemFetchedEvent +import com.muwire.core.filefeeds.FeedManager +import com.muwire.core.filefeeds.UIDownloadFeedItemEvent +import com.muwire.core.filefeeds.UIFilePublishedEvent +import com.muwire.core.filefeeds.UIFeedConfigurationEvent +import com.muwire.core.filefeeds.UIFeedDeletedEvent +import com.muwire.core.filefeeds.UIFeedUpdateEvent +import com.muwire.core.filefeeds.UIFileUnpublishedEvent import com.muwire.core.files.FileDownloadedEvent import com.muwire.core.files.FileHashedEvent import com.muwire.core.files.FileHasher @@ -116,6 +126,8 @@ public class Core { final CertificateManager certificateManager final ChatServer chatServer final ChatManager chatManager + final FeedManager feedManager + private final FeedClient feedClient private final Router router @@ -269,6 +281,8 @@ public class Core { eventBus.register(FileHashedEvent.class, persisterFolderService) eventBus.register(FileUnsharedEvent.class, persisterFolderService) eventBus.register(UICommentEvent.class, persisterFolderService) + eventBus.register(UIFilePublishedEvent.class, persisterFolderService) + eventBus.register(UIFileUnpublishedEvent.class, persisterFolderService) log.info("initializing host cache") File hostStorage = new File(home, "hosts.json") @@ -311,6 +325,19 @@ public class Core { register(TrustEvent.class, chatServer) } + log.info("initializing feed manager") + feedManager = new FeedManager(eventBus, home) + eventBus.with { + register(FeedItemFetchedEvent.class, feedManager) + register(FeedFetchEvent.class, feedManager) + register(UIFeedConfigurationEvent.class, feedManager) + register(UIFeedDeletedEvent.class, feedManager) + } + + log.info("initializing feed client") + feedClient = new FeedClient(i2pConnector, eventBus, me, feedManager) + eventBus.register(UIFeedUpdateEvent.class, feedClient) + log.info "initializing results sender" ResultsSender resultsSender = new ResultsSender(eventBus, i2pConnector, me, props, certificateManager, chatServer) @@ -322,6 +349,7 @@ public class Core { log.info("initializing download manager") downloadManager = new DownloadManager(eventBus, trustService, meshManager, props, i2pConnector, home, me) eventBus.register(UIDownloadEvent.class, downloadManager) + eventBus.register(UIDownloadFeedItemEvent.class, downloadManager) eventBus.register(UILoadedEvent.class, downloadManager) eventBus.register(FileDownloadedEvent.class, downloadManager) eventBus.register(UIDownloadCancelledEvent.class, downloadManager) @@ -391,6 +419,8 @@ public class Core { connectionEstablisher.start() hostCache.waitForLoad() updateClient?.start() + feedManager.start() + feedClient.start() } public void shutdown() { @@ -424,6 +454,10 @@ public class Core { chatServer.stop() log.info("shutting down chat manager") chatManager.shutdown() + log.info("shutting down feed manager") + feedManager.stop() + log.info("shutting down feed client") + feedClient.stop() log.info("shutting down connection manager") connectionManager.shutdown() log.info("killing i2p session") diff --git a/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy b/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy index b820ce1e..03d2b154 100644 --- a/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy +++ b/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy @@ -31,6 +31,16 @@ class MuWireSettings { boolean shareHiddenFiles boolean searchComments boolean browseFiles + + boolean fileFeed + boolean advertiseFeed + boolean autoPublishSharedFiles + boolean defaultFeedAutoDownload + int defaultFeedUpdateInterval + int defaultFeedItemsToKeep + boolean defaultFeedSequential + + boolean startChatServer int maxChatConnections boolean advertiseChat @@ -82,6 +92,16 @@ class MuWireSettings { outBw = Integer.valueOf(props.getProperty("outBw","128")) searchComments = Boolean.valueOf(props.getProperty("searchComments","true")) browseFiles = Boolean.valueOf(props.getProperty("browseFiles","true")) + + // feed settings + fileFeed = Boolean.valueOf(props.getProperty("fileFeed","true")) + advertiseFeed = Boolean.valueOf(props.getProperty("advertiseFeed","true")) + autoPublishSharedFiles = Boolean.valueOf(props.getProperty("autoPublishSharedFiles", "false")) + defaultFeedAutoDownload = Boolean.valueOf(props.getProperty("defaultFeedAutoDownload", "false")) + defaultFeedItemsToKeep = Integer.valueOf(props.getProperty("defaultFeedItemsToKeep", "1000")) + defaultFeedSequential = Boolean.valueOf(props.getProperty("defaultFeedSequential", "false")) + defaultFeedUpdateInterval = Integer.valueOf(props.getProperty("defaultFeedUpdateInterval", "60")) + speedSmoothSeconds = Integer.valueOf(props.getProperty("speedSmoothSeconds","60")) totalUploadSlots = Integer.valueOf(props.getProperty("totalUploadSlots","-1")) uploadSlotsPerUser = Integer.valueOf(props.getProperty("uploadSlotsPerUser","-1")) @@ -137,6 +157,16 @@ class MuWireSettings { props.setProperty("outBw", String.valueOf(outBw)) props.setProperty("searchComments", String.valueOf(searchComments)) props.setProperty("browseFiles", String.valueOf(browseFiles)) + + // feed settings + props.setProperty("fileFeed", String.valueOf(fileFeed)) + props.setProperty("advertiseFeed", String.valueOf(advertiseFeed)) + props.setProperty("autoPublishSharedFiles", String.valueOf(autoPublishSharedFiles)) + props.setProperty("defaultFeedAutoDownload", String.valueOf(defaultFeedAutoDownload)) + props.setProperty("defaultFeedItemsToKeep", String.valueOf(defaultFeedItemsToKeep)) + props.setProperty("defaultFeedSequential", String.valueOf(defaultFeedSequential)) + props.setProperty("defaultFeedUpdateInterval", String.valueOf(defaultFeedUpdateInterval)) + props.setProperty("speedSmoothSeconds", String.valueOf(speedSmoothSeconds)) props.setProperty("totalUploadSlots", String.valueOf(totalUploadSlots)) props.setProperty("uploadSlotsPerUser", String.valueOf(uploadSlotsPerUser)) diff --git a/core/src/main/groovy/com/muwire/core/connection/ConnectionAcceptor.groovy b/core/src/main/groovy/com/muwire/core/connection/ConnectionAcceptor.groovy index b1fcbaf3..720387ef 100644 --- a/core/src/main/groovy/com/muwire/core/connection/ConnectionAcceptor.groovy +++ b/core/src/main/groovy/com/muwire/core/connection/ConnectionAcceptor.groovy @@ -15,9 +15,11 @@ import com.muwire.core.EventBus import com.muwire.core.InfoHash import com.muwire.core.MuWireSettings import com.muwire.core.Persona +import com.muwire.core.SharedFile import com.muwire.core.chat.ChatServer import com.muwire.core.filecert.Certificate import com.muwire.core.filecert.CertificateManager +import com.muwire.core.filefeeds.FeedItems import com.muwire.core.files.FileManager import com.muwire.core.hostcache.HostCache import com.muwire.core.trust.TrustLevel @@ -161,6 +163,9 @@ class ConnectionAcceptor { case (byte)'I': processIRC(e) break + case (byte)'F': + processFEED(e) + break default: throw new Exception("Invalid read $read") } @@ -310,6 +315,9 @@ class ConnectionAcceptor { boolean chat = false if (headers.containsKey('Chat')) chat = Boolean.parseBoolean(headers['Chat']) + boolean feed = false + if (headers.containsKey('Feed')) + feed = Boolean.parseBoolean(headers['Feed']) byte [] personaBytes = Base64.decode(headers['Sender']) Persona sender = new Persona(new ByteArrayInputStream(personaBytes)) @@ -329,6 +337,7 @@ class ConnectionAcceptor { def json = slurper.parse(payload) results[i] = ResultsParser.parse(sender, resultsUUID, json) results[i].chat = chat + results[i].feed = feed } eventBus.publish(new UIResultBatchEvent(uuid: resultsUUID, results: results)) } catch (IOException bad) { @@ -374,6 +383,9 @@ class ConnectionAcceptor { boolean chat = chatServer.running.get() && settings.advertiseChat os.write("Chat: ${chat}\r\n".getBytes(StandardCharsets.US_ASCII)) + boolean feed = settings.fileFeed && settings.advertiseFeed + os.write("Feed: ${feed}\r\n".getBytes(StandardCharsets.US_ASCII)) + os.write("\r\n".getBytes(StandardCharsets.US_ASCII)) DataOutputStream dos = new DataOutputStream(new GZIPOutputStream(os)) @@ -524,5 +536,56 @@ class ConnectionAcceptor { throw new Exception("Invalid IRC connection") chatServer.handle(e) } + + private void processFEED(Endpoint e) { + try { + byte[] EED = new byte[5]; + DataInputStream dis = new DataInputStream(e.getInputStream()) + dis.readFully(EED); + if (EED != "EED\r\n".getBytes(StandardCharsets.US_ASCII)) + throw new Exception("Invalid FEED connection") + + OutputStream os = e.getOutputStream() + + Map headers = DataUtil.readAllHeaders(dis) + if (!headers.containsKey("Persona")) + throw new Exception("Persona header missing") + Persona requestor = new Persona(new ByteArrayInputStream(Base64.decode(headers['Persona']))) + if (requestor.destination != e.destination) + throw new Exception("Requestor persona mismatch") + + if (!settings.fileFeed) { + os.write("403 Not Allowed\r\n\r\n".getBytes(StandardCharsets.US_ASCII)) + os.flush() + e.close() + return + } + + long timestamp = 0 + if (headers.containsKey("Timestamp")) { + timestamp = Long.parseLong(headers['Timestamp']) + } + + List published = fileManager.getPublishedSince(timestamp) + + os.write("200 OK\r\n".getBytes(StandardCharsets.US_ASCII)) + os.write("Count: ${published.size()}\r\n".getBytes(StandardCharsets.US_ASCII)); + os.write("\r\n".getBytes(StandardCharsets.US_ASCII)) + + DataOutputStream dos = new DataOutputStream(new GZIPOutputStream(os)) + JsonOutput jsonOutput = new JsonOutput() + published.each { + int certificates = certificateManager.getByInfoHash(new InfoHash(it.getRoot())).size() + def obj = FeedItems.sharedFileToObj(it, certificates) + def json = jsonOutput.toJson(obj) + dos.writeShort((short)json.length()) + dos.write(json.getBytes(StandardCharsets.US_ASCII)) + } + dos.flush() + dos.close() + } finally { + e.close() + } + } } diff --git a/core/src/main/groovy/com/muwire/core/download/DownloadManager.groovy b/core/src/main/groovy/com/muwire/core/download/DownloadManager.groovy index e9ac7c76..6638914f 100644 --- a/core/src/main/groovy/com/muwire/core/download/DownloadManager.groovy +++ b/core/src/main/groovy/com/muwire/core/download/DownloadManager.groovy @@ -1,6 +1,7 @@ package com.muwire.core.download import com.muwire.core.connection.I2PConnector +import com.muwire.core.filefeeds.UIDownloadFeedItemEvent import com.muwire.core.files.FileDownloadedEvent import com.muwire.core.files.FileHasher import com.muwire.core.mesh.Mesh @@ -62,11 +63,6 @@ public class DownloadManager { public void onUIDownloadEvent(UIDownloadEvent e) { - - File incompletes = muSettings.incompleteLocation - if (incompletes == null) - incompletes = new File(home, "incompletes") - incompletes.mkdirs() def size = e.result[0].size def infohash = e.result[0].infohash @@ -79,12 +75,29 @@ public class DownloadManager { destinations.addAll(e.sources) destinations.remove(me.destination) - Pieces pieces = getPieces(infohash, size, pieceSize, e.sequential) + doDownload(infohash, e.target, size, pieceSize, e.sequential, destinations) - def downloader = new Downloader(eventBus, this, me, e.target, size, - infohash, pieceSize, connector, destinations, - incompletes, pieces) - downloaders.put(infohash, downloader) + } + + public void onUIDownloadFeedItemEvent(UIDownloadFeedItemEvent e) { + Set singleSource = new HashSet<>() + singleSource.add(e.item.getPublisher().getDestination()) + doDownload(e.item.getInfoHash(), e.target, e.item.getSize(), e.item.getPieceSize(), + e.sequential, singleSource) + } + + private void doDownload(InfoHash infoHash, File target, long size, int pieceSize, + boolean sequential, Set destinations) { + File incompletes = muSettings.incompleteLocation + if (incompletes == null) + incompletes = new File(home, "incompletes") + incompletes.mkdirs() + + Pieces pieces = getPieces(infoHash, size, pieceSize, sequential) + def downloader = new Downloader(eventBus, this, me, target, size, + infoHash, pieceSize, connector, destinations, + incompletes, pieces) + downloaders.put(infoHash, downloader) persistDownloaders() executor.execute({downloader.download()} as Runnable) eventBus.publish(new DownloadStartedEvent(downloader : downloader)) diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/FeedClient.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/FeedClient.groovy new file mode 100644 index 00000000..a1978a57 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/FeedClient.groovy @@ -0,0 +1,110 @@ +package com.muwire.core.filefeeds + +import java.util.logging.Level +import java.nio.charset.StandardCharsets +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.zip.GZIPInputStream + +import com.muwire.core.EventBus +import com.muwire.core.Persona +import com.muwire.core.connection.Endpoint +import com.muwire.core.connection.I2PConnector +import com.muwire.core.util.DataUtil + +import groovy.json.JsonSlurper +import groovy.util.logging.Log + +@Log +class FeedClient { + + private final I2PConnector connector + private final EventBus eventBus + private final Persona me + private final FeedManager feedManager + + private final ExecutorService feedFetcher = Executors.newCachedThreadPool() + private final Timer feedUpdater = new Timer("feed-updater", true) + + FeedClient(I2PConnector connector, EventBus eventBus, Persona me, FeedManager feedManager) { + this.connector = connector + this.eventBus = eventBus + this.me = me + this.feedManager = feedManager + } + + private void start() { + feedUpdater.schedule({updateAnyFeeds()} as TimerTask, 60000, 60000) + } + + private void stop() { + feedUpdater.cancel() + feedFetcher.shutdown() + } + + private void updateAnyFeeds() { + feedManager.getFeedsToUpdate().each { feed -> + feedFetcher.execute({updateFeed(feed)} as Runnable) + } + } + + void onUIFeedUpdateEvent(UIFeedUpdateEvent e) { + Feed feed = feedManager.getFeed(e.host) + if (feed == null) { + log.severe("UI request to update non-existent feed " + e.host.getHumanReadableName()) + return + } + + feedFetcher.execute({updateFeed(feed)} as Runnable) + } + + private void updateFeed(Feed feed) { + log.info("updating feed " + feed.getPublisher().getHumanReadableName()) + Endpoint endpoint = null + try { + eventBus.publish(new FeedFetchEvent(host : feed.getPublisher(), status : FeedFetchStatus.CONNECTING)) + feed.setLastUpdateAttempt(System.currentTimeMillis()) + endpoint = connector.connect(feed.getPublisher().getDestination()) + OutputStream os = endpoint.getOutputStream() + os.write("FEED\r\n".getBytes(StandardCharsets.US_ASCII)) + os.write("Persona:${me.toBase64()}\r\n".getBytes(StandardCharsets.US_ASCII)) + os.write("Timestamp:${feed.getLastUpdated()}\r\n".getBytes(StandardCharsets.US_ASCII)) + os.write("\r\n".getBytes(StandardCharsets.US_ASCII)) + os.flush() + + InputStream is = endpoint.getInputStream() + String code = DataUtil.readTillRN(is) + if (!code.startsWith("200")) + throw new IOException("Invalid code $code") + + // parse all headers + Map headers = DataUtil.readAllHeaders(is) + + if (!headers.containsKey("Count")) + throw new IOException("No count header") + + int items = Integer.parseInt(headers['Count']) + + eventBus.publish(new FeedFetchEvent(host : feed.getPublisher(), status : FeedFetchStatus.FETCHING, totalItems: items)) + + JsonSlurper slurper = new JsonSlurper() + DataInputStream dis = new DataInputStream(new GZIPInputStream(is)) + for (int i = 0; i < items; i++) { + int size = dis.readUnsignedShort() + byte [] tmp = new byte[size] + dis.readFully(tmp) + def json = slurper.parse(tmp) + FeedItem item = FeedItems.objToFeedItem(json, feed.getPublisher()) + eventBus.publish(new FeedItemFetchedEvent(item: item)) + } + + eventBus.publish(new FeedFetchEvent(host : feed.getPublisher(), status : FeedFetchStatus.FINISHED)) + } catch (Exception bad) { + log.log(Level.WARNING, "Feed update failed", bad) + eventBus.publish(new FeedFetchEvent(host : feed.getPublisher(), status : FeedFetchStatus.FAILED)) + } finally { + endpoint?.close() + } + } +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/FeedFetchEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/FeedFetchEvent.groovy new file mode 100644 index 00000000..202e8151 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/FeedFetchEvent.groovy @@ -0,0 +1,10 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event +import com.muwire.core.Persona + +class FeedFetchEvent extends Event { + Persona host + FeedFetchStatus status + int totalItems +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/FeedItemFetchedEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/FeedItemFetchedEvent.groovy new file mode 100644 index 00000000..75885f5a --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/FeedItemFetchedEvent.groovy @@ -0,0 +1,7 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event + +class FeedItemFetchedEvent extends Event { + FeedItem item +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/FeedItemLoadedEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/FeedItemLoadedEvent.groovy new file mode 100644 index 00000000..146f3708 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/FeedItemLoadedEvent.groovy @@ -0,0 +1,7 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event + +class FeedItemLoadedEvent extends Event { + FeedItem item +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/FeedItems.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/FeedItems.groovy new file mode 100644 index 00000000..41b16959 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/FeedItems.groovy @@ -0,0 +1,79 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.InfoHash +import com.muwire.core.Persona +import com.muwire.core.SharedFile +import com.muwire.core.files.FileHasher +import com.muwire.core.util.DataUtil + +import net.i2p.data.Base64 + +class FeedItems { + + public static def sharedFileToObj(SharedFile sf, int certificates) { + def json = [:] + json.type = "FeedItem" + json.version = 1 + json.name = Base64.encode(DataUtil.encodei18nString(sf.getFile().getName())) + json.infoHash = Base64.encode(sf.getRoot()) + json.size = sf.getCachedLength() + json.pieceSize = sf.getPieceSize() + + if (sf.getComment() != null) + json.comment = sf.getComment() + + json.certificates = certificates + + json.timestamp = sf.getPublishedTimestamp() + + json + } + + public static FeedItem objToFeedItem(def obj, Persona publisher) throws InvalidFeedItemException { + if (obj.timestamp == null) + throw new InvalidFeedItemException("No timestamp"); + if (obj.name == null) + throw new InvalidFeedItemException("No name"); + if (obj.size == null || obj.size <= 0 || obj.size > FileHasher.MAX_SIZE) + throw new InvalidFeedItemException("length missing or invalid ${obj.size}") + if (obj.pieceSize == null || obj.pieceSize < FileHasher.MIN_PIECE_SIZE_POW2 || obj.pieceSize > FileHasher.MAX_PIECE_SIZE_POW2) + throw new InvalidFeedItemException("piece size missing or invalid ${obj.pieceSize}") + if (obj.infoHash == null) + throw new InvalidFeedItemException("Infohash missing") + + + InfoHash infoHash + try { + infoHash = new InfoHash(Base64.decode(obj.infoHash)) + } catch (Exception bad) { + throw new InvalidFeedItemException("Invalid infohash", bad) + } + + String name + try { + name = DataUtil.readi18nString(Base64.decode(obj.name)) + } catch (Exception bad) { + throw new InvalidFeedItemException("Invalid name", bad) + } + + int certificates = 0 + if (obj.certificates != null) + certificates = obj.certificates + + new FeedItem(publisher, obj.timestamp, name, obj.size, obj.pieceSize, infoHash, certificates, obj.comment) + } + + public static def feedItemToObj(FeedItem item) { + def json = [:] + json.type = "FeedItem" + json.version = 1 + json.name = Base64.encode(DataUtil.encodei18nString(item.getName())) + json.infoHash = Base64.encode(item.getInfoHash().getRoot()) + json.size = item.getSize() + json.pieceSize = item.getPieceSize() + json.timestamp = item.getTimestamp() + json.certificates = item.getCertificates() + json.comment = item.getComment() + json + } +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/FeedLoadedEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/FeedLoadedEvent.groovy new file mode 100644 index 00000000..7e92554e --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/FeedLoadedEvent.groovy @@ -0,0 +1,7 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event + +class FeedLoadedEvent extends Event { + Feed feed +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/FeedManager.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/FeedManager.groovy new file mode 100644 index 00000000..f0b6dcb5 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/FeedManager.groovy @@ -0,0 +1,225 @@ +package com.muwire.core.filefeeds + +import java.nio.file.Files +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.stream.Collectors + +import com.muwire.core.EventBus +import com.muwire.core.Persona + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.util.logging.Log + +import net.i2p.data.Base64 +import net.i2p.util.ConcurrentHashSet + +@Log +class FeedManager { + + private final EventBus eventBus + private final File metadataFolder, itemsFolder + private final Map feeds = new ConcurrentHashMap<>() + private final Map> feedItems = new ConcurrentHashMap<>() + + private final ExecutorService persister = Executors.newSingleThreadExecutor({r -> + new Thread(r, "feed persister") + } as ThreadFactory) + + + FeedManager(EventBus eventBus, File home) { + this.eventBus = eventBus + File feedsFolder = new File(home, "filefeeds") + if (!feedsFolder.exists()) + feedsFolder.mkdir() + this.metadataFolder = new File(feedsFolder, "metadata") + if (!metadataFolder.exists()) + metadataFolder.mkdir() + this.itemsFolder = new File(feedsFolder, "items") + if (!itemsFolder.exists()) + itemsFolder.mkdir() + } + + public Feed getFeed(Persona persona) { + feeds.get(persona) + } + + public Set getFeedItems(Persona persona) { + feedItems.getOrDefault(persona, Collections.emptySet()) + } + + public List getFeedsToUpdate() { + long now = System.currentTimeMillis() + feeds.values().stream(). + filter({Feed f -> !f.getStatus().isActive()}). + filter({Feed f -> f.getLastUpdateAttempt() + f.getUpdateInterval() <= now}) + .collect(Collectors.toList()) + } + + void start() { + log.info("starting feed manager") + persister.submit({loadFeeds()} as Runnable) + persister.submit({loadItems()} as Runnable) + } + + void stop() { + persister.shutdown() + } + + private void loadFeeds() { + def slurper = new JsonSlurper() + Files.walk(metadataFolder.toPath()). + filter( { it.getFileName().toString().endsWith(".json")}). + forEach( { + def parsed = slurper.parse(it.toFile()) + Persona publisher = new Persona(new ByteArrayInputStream(Base64.decode(parsed.publisher))) + Feed feed = new Feed(publisher) + feed.setUpdateInterval(parsed.updateInterval) + feed.setLastUpdated(parsed.lastUpdated) + feed.setLastUpdateAttempt(parsed.lastUpdateAttempt) + feed.setItemsToKeep(parsed.itemsToKeep) + feed.setAutoDownload(parsed.autoDownload) + feed.setSequential(parsed.sequential) + + feed.setStatus(FeedFetchStatus.IDLE) + + feeds.put(feed.getPublisher(), feed) + + eventBus.publish(new FeedLoadedEvent(feed : feed)) + }) + } + + private void loadItems() { + def slurper = new JsonSlurper() + feeds.keySet().each { persona -> + File itemsFile = getItemsFile(feeds[persona]) + if (!itemsFile.exists()) + return // no items yet? + itemsFile.eachLine { line -> + def parsed = slurper.parseText(line) + FeedItem item = FeedItems.objToFeedItem(parsed, persona) + Set items = feedItems.get(persona) + if (items == null) { + items = new ConcurrentHashSet<>() + feedItems.put(persona, items) + } + items.add(item) + eventBus.publish(new FeedItemLoadedEvent(item : item)) + } + } + } + + void onFeedItemFetchedEvent(FeedItemFetchedEvent e) { + Set set = feedItems.get(e.item.getPublisher()) + if (set == null) { + set = new ConcurrentHashSet<>() + feedItems.put(e.getItem().getPublisher(), set) + } + set.add(e.item) + } + + void onFeedFetchEvent(FeedFetchEvent e) { + + Feed feed = feeds.get(e.host) + if (feed == null) { + log.severe("Fetching non-existent feed " + e.host.getHumanReadableName()) + return + } + + feed.setStatus(e.status) + + if (e.status.isActive()) + return + + if (e.status == FeedFetchStatus.FINISHED) { + feed.setStatus(FeedFetchStatus.IDLE) + feed.setLastUpdated(e.getTimestamp()) + } + // save feed items, then save feed. This will save partial fetches too + // which is ok because the items are stored in a Set + persister.submit({saveFeedItems(e.host)} as Runnable) + persister.submit({saveFeedMetadata(feed)} as Runnable) + } + + void onUIFeedConfigurationEvent(UIFeedConfigurationEvent e) { + feeds.put(e.feed.getPublisher(), e.feed) + persister.submit({saveFeedMetadata(e.feed)} as Runnable) + } + + void onUIFeedDeletedEvent(UIFeedDeletedEvent e) { + Feed f = feeds.get(e.host) + if (f == null) { + log.severe("Deleting a non-existing feed " + e.host.getHumanReadableName()) + return + } + persister.submit({deleteFeed(f)} as Runnable) + } + + private void saveFeedItems(Persona publisher) { + Set set = feedItems.get(publisher) + if (set == null) + return // can happen if nothing was published + + Feed feed = feeds[publisher] + if (feed == null) { + log.severe("Persisting items for non-existing feed " + publisher.getHumanReadableName()) + return + } + + if (feed.getItemsToKeep() == 0) + return + + List list = new ArrayList<>(set) + if (feed.getItemsToKeep() > 0 && list.size() > feed.getItemsToKeep()) { + log.info("will persist ${feed.getItemsToKeep()}/${list.size()} items") + list.sort({l, r -> + Long.compare(r.getTimestamp(), l.getTimestamp()) + } as Comparator) + list = list[0..feed.getItemsToKeep() - 1] + } + + + File itemsFile = getItemsFile(feed) + itemsFile.withPrintWriter { writer -> + list.each { item -> + def obj = FeedItems.feedItemToObj(item) + def json = JsonOutput.toJson(obj) + writer.println(json) + } + } + } + + private void saveFeedMetadata(Feed feed) { + File metadataFile = getMetadataFile(feed) + metadataFile.withPrintWriter { writer -> + def json = [:] + json.publisher = feed.getPublisher().toBase64() + json.itemsToKeep = feed.getItemsToKeep() + json.lastUpdated = feed.getLastUpdated() + json.updateInterval = feed.getUpdateInterval() + json.autoDownload = feed.isAutoDownload() + json.sequential = feed.isSequential() + json.lastUpdateAttempt = feed.getLastUpdateAttempt() + json = JsonOutput.toJson(json) + writer.println(json) + } + } + + private void deleteFeed(Feed feed) { + feeds.remove(feed.getPublisher()) + feedItems.remove(feed.getPublisher()) + getItemsFile(feed).delete() + getMetadataFile(feed).delete() + } + + private File getItemsFile(Feed feed) { + return new File(itemsFolder, feed.getPublisher().destination.toBase32() + ".json") + } + + private File getMetadataFile(Feed feed) { + return new File(metadataFolder, feed.getPublisher().destination.toBase32() + ".json") + } +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/UIDownloadFeedItemEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/UIDownloadFeedItemEvent.groovy new file mode 100644 index 00000000..06ba9b4a --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/UIDownloadFeedItemEvent.groovy @@ -0,0 +1,9 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event + +class UIDownloadFeedItemEvent extends Event { + FeedItem item + File target + boolean sequential +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedConfigurationEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedConfigurationEvent.groovy new file mode 100644 index 00000000..3e0e176e --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedConfigurationEvent.groovy @@ -0,0 +1,12 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event + +/** + * Emitted when configuration of a feed changes. + * The object should already contain the updated values. + */ +class UIFeedConfigurationEvent extends Event { + Feed feed + boolean newFeed +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedDeletedEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedDeletedEvent.groovy new file mode 100644 index 00000000..db574870 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedDeletedEvent.groovy @@ -0,0 +1,8 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event +import com.muwire.core.Persona + +class UIFeedDeletedEvent extends Event { + Persona host +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedUpdateEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedUpdateEvent.groovy new file mode 100644 index 00000000..836cd4f9 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/UIFeedUpdateEvent.groovy @@ -0,0 +1,8 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event +import com.muwire.core.Persona + +class UIFeedUpdateEvent extends Event { + Persona host +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/UIFilePublishedEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/UIFilePublishedEvent.groovy new file mode 100644 index 00000000..b494ce71 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/UIFilePublishedEvent.groovy @@ -0,0 +1,8 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event +import com.muwire.core.SharedFile + +class UIFilePublishedEvent extends Event { + SharedFile sf +} diff --git a/core/src/main/groovy/com/muwire/core/filefeeds/UIFileUnpublishedEvent.groovy b/core/src/main/groovy/com/muwire/core/filefeeds/UIFileUnpublishedEvent.groovy new file mode 100644 index 00000000..ee6f5ce0 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/filefeeds/UIFileUnpublishedEvent.groovy @@ -0,0 +1,8 @@ +package com.muwire.core.filefeeds + +import com.muwire.core.Event +import com.muwire.core.SharedFile + +class UIFileUnpublishedEvent extends Event { + SharedFile sf +} diff --git a/core/src/main/groovy/com/muwire/core/files/BasePersisterService.groovy b/core/src/main/groovy/com/muwire/core/files/BasePersisterService.groovy index 356bb419..322556d5 100644 --- a/core/src/main/groovy/com/muwire/core/files/BasePersisterService.groovy +++ b/core/src/main/groovy/com/muwire/core/files/BasePersisterService.groovy @@ -47,7 +47,7 @@ abstract class BasePersisterService extends Service{ int pieceSize = 0 if (json.pieceSize != null) pieceSize = json.pieceSize - + if (json.sources != null) { List sources = (List)json.sources Set sourceSet = sources.stream().map({ d -> new Destination(d.toString())}).collect Collectors.toSet() @@ -94,10 +94,19 @@ abstract class BasePersisterService extends Service{ if (json.pieceSize != null) pieceSize = json.pieceSize + boolean published = false + long publishedTimestamp = -1 + if (json.published != null && json.published) { + published = true + publishedTimestamp = json.publishedTimestamp + } + if (json.sources != null) { List sources = (List)json.sources Set sourceSet = sources.stream().map({ d -> new Destination(d.toString())}).collect Collectors.toSet() DownloadedFile df = new DownloadedFile(file, ih.getRoot(), pieceSize, sourceSet) + if (published) + df.publish(publishedTimestamp) df.setComment(json.comment) return new FileLoadedEvent(loadedFile : df, infoHash: ih) } @@ -105,6 +114,8 @@ abstract class BasePersisterService extends Service{ SharedFile sf = new SharedFile(file, ih.getRoot(), pieceSize) sf.setComment(json.comment) + if (published) + sf.publish(publishedTimestamp) if (json.downloaders != null) sf.getDownloaders().addAll(json.downloaders) if (json.searchers != null) { @@ -146,6 +157,11 @@ abstract class BasePersisterService extends Service{ if (sf instanceof DownloadedFile) { json.sources = sf.sources.stream().map( {d -> d.toBase64()}).collect(Collectors.toList()) } + + if (sf.isPublished()) { + json.published = true + json.publishedTimestamp = sf.getPublishedTimestamp() + } json } diff --git a/core/src/main/groovy/com/muwire/core/files/FileHashedEvent.groovy b/core/src/main/groovy/com/muwire/core/files/FileHashedEvent.groovy index cadaaa99..4db732e8 100644 --- a/core/src/main/groovy/com/muwire/core/files/FileHashedEvent.groovy +++ b/core/src/main/groovy/com/muwire/core/files/FileHashedEvent.groovy @@ -12,7 +12,7 @@ class FileHashedEvent extends Event { @Override public String toString() { - super.toString() + " sharedFile " + sharedFile?.file.getAbsolutePath() + " error: $error" + super.toString() + " sharedFile " + sharedFile?.file?.getAbsolutePath() + " error: $error" } } diff --git a/core/src/main/groovy/com/muwire/core/files/FileManager.groovy b/core/src/main/groovy/com/muwire/core/files/FileManager.groovy index 16864be7..b6bcf3a9 100644 --- a/core/src/main/groovy/com/muwire/core/files/FileManager.groovy +++ b/core/src/main/groovy/com/muwire/core/files/FileManager.groovy @@ -1,5 +1,8 @@ package com.muwire.core.files +import java.util.stream.Collectors +import java.util.stream.Stream + import com.muwire.core.EventBus import com.muwire.core.InfoHash import com.muwire.core.MuWireSettings @@ -190,6 +193,10 @@ class FileManager { Set getSharedFiles(byte []root) { return rootToFiles.get(new InfoHash(root)) } + + boolean isShared(InfoHash infoHash) { + rootToFiles.containsKey(infoHash) + } void onSearchEvent(SearchEvent e) { // hash takes precedence @@ -254,4 +261,13 @@ class FileManager { settings.negativeFileTree.clear() settings.negativeFileTree.addAll(negativeTree.fileToNode.keySet().collect { it.getAbsolutePath() }) } + + public List getPublishedSince(long timestamp) { + synchronized(fileToSharedFile) { + fileToSharedFile.values().stream(). + filter({sf -> sf.isPublished()}). + filter({sf -> sf.getPublishedTimestamp() >= timestamp}). + collect(Collectors.toList()) + } + } } diff --git a/core/src/main/groovy/com/muwire/core/files/PersisterFolderService.groovy b/core/src/main/groovy/com/muwire/core/files/PersisterFolderService.groovy index b2899f05..ef2814a6 100644 --- a/core/src/main/groovy/com/muwire/core/files/PersisterFolderService.groovy +++ b/core/src/main/groovy/com/muwire/core/files/PersisterFolderService.groovy @@ -1,6 +1,9 @@ package com.muwire.core.files import com.muwire.core.* +import com.muwire.core.filefeeds.UIFilePublishedEvent +import com.muwire.core.filefeeds.UIFileUnpublishedEvent + import groovy.json.JsonOutput import groovy.json.JsonSlurper import groovy.util.logging.Log @@ -56,11 +59,15 @@ class PersisterFolderService extends BasePersisterService { } void onFileHashedEvent(FileHashedEvent hashedEvent) { + if (core.getMuOptions().getAutoPublishSharedFiles() && hashedEvent.sharedFile != null) + hashedEvent.sharedFile.publish(System.currentTimeMillis()) persistFile(hashedEvent.sharedFile, hashedEvent.infoHash) } void onFileDownloadedEvent(FileDownloadedEvent downloadedEvent) { if (core.getMuOptions().getShareDownloadedFiles()) { + if (core.getMuOptions().getAutoPublishSharedFiles()) + downloadedEvent.downloadedFile.publish(System.currentTimeMillis()) persistFile(downloadedEvent.downloadedFile, downloadedEvent.infoHash) } } @@ -92,6 +99,14 @@ class PersisterFolderService extends BasePersisterService { void onUICommentEvent(UICommentEvent e) { persistFile(e.sharedFile,null) } + + void onUIFilePublishedEvent(UIFilePublishedEvent e) { + persistFile(e.sf, null) + } + + void onUIFileUnpublishedEvent(UIFileUnpublishedEvent e) { + persistFile(e.sf, null) + } void load() { log.fine("Loading...") diff --git a/core/src/main/groovy/com/muwire/core/search/ResultsSender.groovy b/core/src/main/groovy/com/muwire/core/search/ResultsSender.groovy index b79df77a..76c02139 100644 --- a/core/src/main/groovy/com/muwire/core/search/ResultsSender.groovy +++ b/core/src/main/groovy/com/muwire/core/search/ResultsSender.groovy @@ -88,7 +88,8 @@ class ResultsSender { sources : suggested, comment : comment, certificates : certificates, - chat : chatServer.running.get() && settings.advertiseChat + chat : chatServer.running.get() && settings.advertiseChat, + feed : settings.fileFeed && settings.advertiseFeed ) uiResultEvents << uiResultEvent } @@ -138,6 +139,8 @@ class ResultsSender { os.write("Count: $results.length\r\n".getBytes(StandardCharsets.US_ASCII)) boolean chat = chatServer.running.get() && settings.advertiseChat os.write("Chat: $chat\r\n".getBytes(StandardCharsets.US_ASCII)) + boolean feed = settings.fileFeed && settings.advertiseFeed + os.write("Feed: $feed\r\n".getBytes(StandardCharsets.US_ASCII)) os.write("\r\n".getBytes(StandardCharsets.US_ASCII)) DataOutputStream dos = new DataOutputStream(new GZIPOutputStream(os)) results.each { diff --git a/core/src/main/groovy/com/muwire/core/search/UIResultEvent.groovy b/core/src/main/groovy/com/muwire/core/search/UIResultEvent.groovy index 4fad4f90..77ea0957 100644 --- a/core/src/main/groovy/com/muwire/core/search/UIResultEvent.groovy +++ b/core/src/main/groovy/com/muwire/core/search/UIResultEvent.groovy @@ -18,7 +18,8 @@ class UIResultEvent extends Event { boolean browse int certificates boolean chat - + boolean feed + @Override public String toString() { super.toString() + "name:$name size:$size sender:${sender.getHumanReadableName()} pieceSize $pieceSize" diff --git a/core/src/main/java/com/muwire/core/SharedFile.java b/core/src/main/java/com/muwire/core/SharedFile.java index 8021a494..ab257f54 100644 --- a/core/src/main/java/com/muwire/core/SharedFile.java +++ b/core/src/main/java/com/muwire/core/SharedFile.java @@ -31,6 +31,8 @@ public class SharedFile { private volatile String comment; private final Set downloaders = Collections.synchronizedSet(new HashSet<>()); private final Set searches = Collections.synchronizedSet(new HashSet<>()); + private volatile boolean published; + private volatile long publishedTimestamp; public SharedFile(File file, byte[] root, int pieceSize) throws IOException { this.file = file; @@ -114,6 +116,24 @@ public class SharedFile { public void addDownloader(String name) { downloaders.add(name); } + + public void publish(long timestamp) { + published = true; + publishedTimestamp = timestamp; + } + + public void unpublish() { + published = false; + publishedTimestamp = 0; + } + + public boolean isPublished() { + return published; + } + + public long getPublishedTimestamp() { + return publishedTimestamp; + } @Override public int hashCode() { diff --git a/core/src/main/java/com/muwire/core/filefeeds/Feed.java b/core/src/main/java/com/muwire/core/filefeeds/Feed.java new file mode 100644 index 00000000..e42894f6 --- /dev/null +++ b/core/src/main/java/com/muwire/core/filefeeds/Feed.java @@ -0,0 +1,81 @@ +package com.muwire.core.filefeeds; + +import com.muwire.core.Persona; + +public class Feed { + + private final Persona publisher; + + private int updateInterval; + private long lastUpdated; + private volatile long lastUpdateAttempt; + private int itemsToKeep; + private boolean autoDownload; + private boolean sequential; + private FeedFetchStatus status; + + public Feed(Persona publisher) { + this.publisher = publisher; + this.status = FeedFetchStatus.IDLE; + } + + public int getUpdateInterval() { + return updateInterval; + } + + public void setUpdateInterval(int updateInterval) { + this.updateInterval = updateInterval; + } + + public long getLastUpdated() { + return lastUpdated; + } + + public void setLastUpdated(long lastUpdated) { + this.lastUpdated = lastUpdated; + } + + public int getItemsToKeep() { + return itemsToKeep; + } + + public void setItemsToKeep(int itemsToKeep) { + this.itemsToKeep = itemsToKeep; + } + + public boolean isAutoDownload() { + return autoDownload; + } + + public void setAutoDownload(boolean autoDownload) { + this.autoDownload = autoDownload; + } + + public Persona getPublisher() { + return publisher; + } + + public void setStatus(FeedFetchStatus status) { + this.status = status; + } + + public FeedFetchStatus getStatus() { + return status; + } + + public void setSequential(boolean sequential) { + this.sequential = sequential; + } + + public boolean isSequential() { + return sequential; + } + + public void setLastUpdateAttempt(long lastUpdateAttempt) { + this.lastUpdateAttempt = lastUpdateAttempt; + } + + public long getLastUpdateAttempt() { + return lastUpdateAttempt; + } +} diff --git a/core/src/main/java/com/muwire/core/filefeeds/FeedFetchStatus.java b/core/src/main/java/com/muwire/core/filefeeds/FeedFetchStatus.java new file mode 100644 index 00000000..f6b1a3d7 --- /dev/null +++ b/core/src/main/java/com/muwire/core/filefeeds/FeedFetchStatus.java @@ -0,0 +1,19 @@ +package com.muwire.core.filefeeds; + +public enum FeedFetchStatus { + IDLE(false), + CONNECTING(true), + FETCHING(true), + FINISHED(false), + FAILED(false); + + private final boolean active; + + FeedFetchStatus(boolean active) { + this.active = active; + } + + public boolean isActive() { + return active; + } +} diff --git a/core/src/main/java/com/muwire/core/filefeeds/FeedItem.java b/core/src/main/java/com/muwire/core/filefeeds/FeedItem.java new file mode 100644 index 00000000..a1d5da5b --- /dev/null +++ b/core/src/main/java/com/muwire/core/filefeeds/FeedItem.java @@ -0,0 +1,79 @@ +package com.muwire.core.filefeeds; + +import java.util.Objects; + +import com.muwire.core.InfoHash; +import com.muwire.core.Persona; + +public class FeedItem { + + private final Persona publisher; + private final long timestamp; + private final String name; + private final long size; + private final int pieceSize; + private final InfoHash infoHash; + private final int certificates; + private final String comment; + + public FeedItem(Persona publisher, long timestamp, String name, long size, int pieceSize, InfoHash infoHash, + int certificates, String comment) { + super(); + this.publisher = publisher; + this.timestamp = timestamp; + this.name = name; + this.size = size; + this.pieceSize = pieceSize; + this.infoHash = infoHash; + this.certificates = certificates; + this.comment = comment; + } + + public Persona getPublisher() { + return publisher; + } + + public long getTimestamp() { + return timestamp; + } + + public String getName() { + return name; + } + + public long getSize() { + return size; + } + + public int getPieceSize() { + return pieceSize; + } + + public InfoHash getInfoHash() { + return infoHash; + } + + public int getCertificates() { + return certificates; + } + + public String getComment() { + return comment; + } + + @Override + public int hashCode() { + return Objects.hash(publisher, timestamp, name, infoHash); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof FeedItem)) + return false; + FeedItem other = (FeedItem)o; + return Objects.equals(publisher, other.publisher) && + timestamp == other.timestamp && + Objects.equals(name, other.name) && + Objects.equals(infoHash, other.infoHash); + } +} diff --git a/core/src/main/java/com/muwire/core/filefeeds/InvalidFeedItemException.java b/core/src/main/java/com/muwire/core/filefeeds/InvalidFeedItemException.java new file mode 100644 index 00000000..4c4e430d --- /dev/null +++ b/core/src/main/java/com/muwire/core/filefeeds/InvalidFeedItemException.java @@ -0,0 +1,30 @@ +package com.muwire.core.filefeeds; + +public class InvalidFeedItemException extends Exception { + + public InvalidFeedItemException() { + super(); + } + + public InvalidFeedItemException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + // TODO Auto-generated constructor stub + } + + public InvalidFeedItemException(String message, Throwable cause) { + super(message, cause); + // TODO Auto-generated constructor stub + } + + public InvalidFeedItemException(String message) { + super(message); + // TODO Auto-generated constructor stub + } + + public InvalidFeedItemException(Throwable cause) { + super(cause); + // TODO Auto-generated constructor stub + } + +} diff --git a/gui/griffon-app/conf/Config.groovy b/gui/griffon-app/conf/Config.groovy index 53af343d..a7f1479c 100644 --- a/gui/griffon-app/conf/Config.groovy +++ b/gui/griffon-app/conf/Config.groovy @@ -126,4 +126,9 @@ mvcGroups { view = 'com.muwire.gui.ChatMonitorView' controller = 'com.muwire.gui.ChatMonitorController' } + 'feed-configuration' { + model = 'com.muwire.gui.FeedConfigurationModel' + view = 'com.muwire.gui.FeedConfigurationView' + controller = 'com.muwire.gui.FeedConfigurationController' + } } diff --git a/gui/griffon-app/controllers/com/muwire/gui/BrowseController.groovy b/gui/griffon-app/controllers/com/muwire/gui/BrowseController.groovy index fff00f5c..8c98d6c4 100644 --- a/gui/griffon-app/controllers/com/muwire/gui/BrowseController.groovy +++ b/gui/griffon-app/controllers/com/muwire/gui/BrowseController.groovy @@ -113,7 +113,9 @@ class BrowseController { return def params = [:] - params['result'] = result + params['host'] = result.getSender() + params['infoHash'] = result.getInfohash() + params['name'] = result.getName() params['core'] = core mvcGroup.createMVCGroup("fetch-certificates", params) } diff --git a/gui/griffon-app/controllers/com/muwire/gui/FeedConfigurationController.groovy b/gui/griffon-app/controllers/com/muwire/gui/FeedConfigurationController.groovy new file mode 100644 index 00000000..4cd712f3 --- /dev/null +++ b/gui/griffon-app/controllers/com/muwire/gui/FeedConfigurationController.groovy @@ -0,0 +1,36 @@ +package com.muwire.gui + +import griffon.core.artifact.GriffonController +import griffon.core.controller.ControllerAction +import griffon.inject.MVCMember +import griffon.metadata.ArtifactProviderFor +import javax.annotation.Nonnull + +import com.muwire.core.filefeeds.UIFeedConfigurationEvent + +@ArtifactProviderFor(GriffonController) +class FeedConfigurationController { + @MVCMember @Nonnull + FeedConfigurationModel model + @MVCMember @Nonnull + FeedConfigurationView view + + @ControllerAction + void save() { + + model.feed.setAutoDownload(view.autoDownloadCheckbox.model.isSelected()) + model.feed.setSequential(view.sequentialCheckbox.model.isSelected()) + model.feed.setItemsToKeep(Integer.parseInt(view.itemsToKeepField.text)) + model.feed.setUpdateInterval(Integer.parseInt(view.updateIntervalField.text) * 60000) + + model.core.eventBus.publish(new UIFeedConfigurationEvent(feed : model.feed)) + + cancel() + } + + @ControllerAction + void cancel() { + view.dialog.setVisible(false) + mvcGroup.destroy() + } +} \ No newline at end of file diff --git a/gui/griffon-app/controllers/com/muwire/gui/FetchCertificatesController.groovy b/gui/griffon-app/controllers/com/muwire/gui/FetchCertificatesController.groovy index 0caadd57..b66b7207 100644 --- a/gui/griffon-app/controllers/com/muwire/gui/FetchCertificatesController.groovy +++ b/gui/griffon-app/controllers/com/muwire/gui/FetchCertificatesController.groovy @@ -28,7 +28,7 @@ class FetchCertificatesController { core.eventBus.with { register(CertificateFetchEvent.class, this) register(CertificateFetchedEvent.class, this) - publish(new UIFetchCertificatesEvent(host : model.result.sender, infoHash : model.result.infohash)) + publish(new UIFetchCertificatesEvent(host : model.host, infoHash : model.infoHash)) } } diff --git a/gui/griffon-app/controllers/com/muwire/gui/MainFrameController.groovy b/gui/griffon-app/controllers/com/muwire/gui/MainFrameController.groovy index 298e9b43..b5a578ca 100644 --- a/gui/griffon-app/controllers/com/muwire/gui/MainFrameController.groovy +++ b/gui/griffon-app/controllers/com/muwire/gui/MainFrameController.groovy @@ -30,6 +30,13 @@ import com.muwire.core.download.UIDownloadCancelledEvent import com.muwire.core.download.UIDownloadPausedEvent import com.muwire.core.download.UIDownloadResumedEvent import com.muwire.core.filecert.UICreateCertificateEvent +import com.muwire.core.filefeeds.Feed +import com.muwire.core.filefeeds.FeedItem +import com.muwire.core.filefeeds.UIDownloadFeedItemEvent +import com.muwire.core.filefeeds.UIFeedDeletedEvent +import com.muwire.core.filefeeds.UIFeedUpdateEvent +import com.muwire.core.filefeeds.UIFilePublishedEvent +import com.muwire.core.filefeeds.UIFileUnpublishedEvent import com.muwire.core.files.FileUnsharedEvent import com.muwire.core.search.QueryEvent import com.muwire.core.search.SearchEvent @@ -505,6 +512,105 @@ class MainFrameController { clipboard.setContents(selection, null) } + @ControllerAction + void publish() { + def selectedFiles = view.selectedSharedFiles() + if (selectedFiles == null || selectedFiles.isEmpty()) + return + + if (model.publishButtonText == "Unpublish") { + selectedFiles.each { + it.unpublish() + model.core.eventBus.publish(new UIFileUnpublishedEvent(sf : it)) + } + } else { + long now = System.currentTimeMillis() + selectedFiles.stream().filter({!it.isPublished()}).forEach({ + it.publish(now) + model.core.eventBus.publish(new UIFilePublishedEvent(sf : it)) + }) + } + view.refreshSharedFiles() + } + + @ControllerAction + void updateFileFeed() { + Feed feed = view.selectedFeed() + if (feed == null) + return + model.core.eventBus.publish(new UIFeedUpdateEvent(host: feed.getPublisher())) + } + + @ControllerAction + void unsubscribeFileFeed() { + Feed feed = view.selectedFeed() + if (feed == null) + return + model.core.eventBus.publish(new UIFeedDeletedEvent(host : feed.getPublisher())) + runInsideUIAsync { + model.feeds.remove(feed) + model.feedItems.clear() + view.refreshFeeds() + } + } + + @ControllerAction + void configureFileFeed() { + Feed feed = view.selectedFeed() + if (feed == null) + return + + def params = [:] + params['core'] = core + params['feed'] = feed + mvcGroup.createMVCGroup("feed-configuration", params) + } + + @ControllerAction + void downloadFeedItem() { + List items = view.selectedFeedItems() + if (items == null || items.isEmpty()) + return + Feed f = model.core.getFeedManager().getFeed(items.get(0).getPublisher()) + items.each { + if (!model.canDownload(it.getInfoHash())) + return + File target = new File(application.context.get("muwire-settings").downloadLocation, it.getName()) + model.core.eventBus.publish(new UIDownloadFeedItemEvent(item : it, target : target, sequential : f.isSequential())) + } + view.showDownloadsWindow.call() + } + + @ControllerAction + void viewFeedItemComment() { + List items = view.selectedFeedItems() + if (items == null || items.size() != 1) + return + FeedItem item = items.get(0) + + String groupId = Base64.encode(item.getInfoHash().getRoot()) + Map params = new HashMap<>() + params['text'] = DataUtil.readi18nString(Base64.decode(item.getComment())) + params['name'] = item.getName() + + mvcGroup.createMVCGroup("show-comment", groupId, params) + } + + @ControllerAction + void viewFeedItemCertificates() { + List items = view.selectedFeedItems() + if (items == null || items.size() != 1) + return + FeedItem item = items.get(0) + + def params = [:] + params['core'] = core + params['host'] = item.getPublisher() + params['infoHash'] = item.getInfoHash() + params['name'] = item.getName() + mvcGroup.createMVCGroup("fetch-certificates", params) + } + void startChat(Persona p) { if (!mvcGroup.getChildrenGroups().containsKey(p.getHumanReadableName())) { def params = [:] diff --git a/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy b/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy index 17b854a9..2d5ce460 100644 --- a/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy +++ b/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy @@ -122,7 +122,38 @@ class OptionsController { model.outBw = text settings.outBw = Integer.valueOf(text) } + + // feed saving + + boolean fileFeed = view.fileFeedCheckbox.model.isSelected() + model.fileFeed = fileFeed + settings.fileFeed = fileFeed + + boolean advertiseFeed = view.advertiseFeedCheckbox.model.isSelected() + model.advertiseFeed = advertiseFeed + settings.advertiseFeed = advertiseFeed + + boolean autoPublishSharedFiles = view.autoPublishSharedFilesCheckbox.model.isSelected() + model.autoPublishSharedFiles = autoPublishSharedFiles + settings.autoPublishSharedFiles = autoPublishSharedFiles + + boolean defaultFeedAutoDownload = view.defaultFeedAutoDownloadCheckbox.model.isSelected() + model.defaultFeedAutoDownload = defaultFeedAutoDownload + settings.defaultFeedAutoDownload = defaultFeedAutoDownload + + boolean defaultFeedSequential = view.defaultFeedSequentialCheckbox.model.isSelected() + model.defaultFeedSequential = defaultFeedSequential + settings.defaultFeedSequential = defaultFeedSequential + + String defaultFeedItemsToKeep = view.defaultFeedItemsToKeepField.text + model.defaultFeedItemsToKeep = defaultFeedItemsToKeep + settings.defaultFeedItemsToKeep = Integer.parseInt(defaultFeedItemsToKeep) + + String defaultFeedUpdateInterval = view.defaultFeedUpdateIntervalField.text + model.defaultFeedUpdateInterval = defaultFeedUpdateInterval + settings.defaultFeedUpdateInterval = Integer.parseInt(defaultFeedUpdateInterval) + // trust saving boolean onlyTrusted = view.allowUntrustedCheckbox.model.isSelected() model.onlyTrusted = onlyTrusted diff --git a/gui/griffon-app/controllers/com/muwire/gui/SearchTabController.groovy b/gui/griffon-app/controllers/com/muwire/gui/SearchTabController.groovy index 3d6d7fa8..c5027cae 100644 --- a/gui/griffon-app/controllers/com/muwire/gui/SearchTabController.groovy +++ b/gui/griffon-app/controllers/com/muwire/gui/SearchTabController.groovy @@ -12,6 +12,8 @@ import javax.swing.JOptionPane import com.muwire.core.Core import com.muwire.core.Persona import com.muwire.core.download.UIDownloadEvent +import com.muwire.core.filefeeds.Feed +import com.muwire.core.filefeeds.UIFeedConfigurationEvent import com.muwire.core.search.UIResultEvent import com.muwire.core.trust.TrustEvent import com.muwire.core.trust.TrustLevel @@ -107,6 +109,22 @@ class SearchTabController { mvcGroup.createMVCGroup("browse", groupId, params) } + @ControllerAction + void subscribe() { + def sender = view.selectedSender() + if (sender == null) + return + + Feed feed = new Feed(sender) + feed.setAutoDownload(core.muOptions.defaultFeedAutoDownload) + feed.setSequential(core.muOptions.defaultFeedSequential) + feed.setItemsToKeep(core.muOptions.defaultFeedItemsToKeep) + feed.setUpdateInterval(core.muOptions.defaultFeedUpdateInterval * 60 * 1000) + + core.eventBus.publish(new UIFeedConfigurationEvent(feed : feed, newFeed: true)) + mvcGroup.parentGroup.view.showFeedsWindow.call() + } + @ControllerAction void chat() { def sender = view.selectedSender() @@ -139,7 +157,9 @@ class SearchTabController { return def params = [:] - params['result'] = event + params['host'] = event.getSender() + params['infoHash'] = event.getInfohash() + params['name'] = event.getName() params['core'] = core mvcGroup.createMVCGroup("fetch-certificates", params) } diff --git a/gui/griffon-app/models/com/muwire/gui/FeedConfigurationModel.groovy b/gui/griffon-app/models/com/muwire/gui/FeedConfigurationModel.groovy new file mode 100644 index 00000000..a49b252d --- /dev/null +++ b/gui/griffon-app/models/com/muwire/gui/FeedConfigurationModel.groovy @@ -0,0 +1,26 @@ +package com.muwire.gui + +import com.muwire.core.Core +import com.muwire.core.filefeeds.Feed + +import griffon.core.artifact.GriffonModel +import griffon.transform.Observable +import griffon.metadata.ArtifactProviderFor + +@ArtifactProviderFor(GriffonModel) +class FeedConfigurationModel { + Core core + Feed feed + + @Observable boolean autoDownload + @Observable boolean sequential + @Observable int updateInterval + @Observable int itemsToKeep + + void mvcGroupInit(Map args) { + autoDownload = feed.isAutoDownload() + sequential = feed.isSequential() + updateInterval = feed.getUpdateInterval() / 60000 + itemsToKeep = feed.getItemsToKeep() + } +} \ No newline at end of file diff --git a/gui/griffon-app/models/com/muwire/gui/FetchCertificatesModel.groovy b/gui/griffon-app/models/com/muwire/gui/FetchCertificatesModel.groovy index 3a7630e0..98f22deb 100644 --- a/gui/griffon-app/models/com/muwire/gui/FetchCertificatesModel.groovy +++ b/gui/griffon-app/models/com/muwire/gui/FetchCertificatesModel.groovy @@ -1,6 +1,9 @@ package com.muwire.gui +import com.muwire.core.InfoHash +import com.muwire.core.Persona import com.muwire.core.filecert.CertificateFetchStatus +import com.muwire.core.filefeeds.FeedItem import com.muwire.core.search.UIResultEvent import griffon.core.artifact.GriffonModel @@ -9,7 +12,9 @@ import griffon.metadata.ArtifactProviderFor @ArtifactProviderFor(GriffonModel) class FetchCertificatesModel { - UIResultEvent result + Persona host + InfoHash infoHash + String name @Observable CertificateFetchStatus status @Observable int totalCertificates diff --git a/gui/griffon-app/models/com/muwire/gui/MainFrameModel.groovy b/gui/griffon-app/models/com/muwire/gui/MainFrameModel.groovy index 32351c1c..68057ab0 100644 --- a/gui/griffon-app/models/com/muwire/gui/MainFrameModel.groovy +++ b/gui/griffon-app/models/com/muwire/gui/MainFrameModel.groovy @@ -28,6 +28,12 @@ import com.muwire.core.content.ContentControlEvent import com.muwire.core.download.DownloadStartedEvent import com.muwire.core.download.Downloader import com.muwire.core.filecert.CertificateCreatedEvent +import com.muwire.core.filefeeds.Feed +import com.muwire.core.filefeeds.FeedFetchEvent +import com.muwire.core.filefeeds.FeedItemFetchedEvent +import com.muwire.core.filefeeds.FeedLoadedEvent +import com.muwire.core.filefeeds.UIDownloadFeedItemEvent +import com.muwire.core.filefeeds.UIFeedConfigurationEvent import com.muwire.core.files.AllFilesLoadedEvent import com.muwire.core.files.DirectoryUnsharedEvent import com.muwire.core.files.DirectoryWatchedEvent @@ -61,6 +67,7 @@ import griffon.transform.FXObservable import griffon.transform.Observable import net.i2p.data.Base64 import net.i2p.data.Destination +import net.i2p.util.ConcurrentHashSet import griffon.metadata.ArtifactProviderFor @ArtifactProviderFor(GriffonModel) @@ -89,6 +96,8 @@ class MainFrameModel { def trusted = [] def distrusted = [] def subscriptions = [] + def feeds = [] + def feedItems = [] boolean sessionRestored @@ -103,6 +112,14 @@ class MainFrameModel { @Observable boolean previewButtonEnabled @Observable String resumeButtonText @Observable boolean addCommentButtonEnabled + @Observable boolean publishButtonEnabled + @Observable String publishButtonText + @Observable boolean updateFileFeedButtonEnabled + @Observable boolean unsubscribeFileFeedButtonEnabled + @Observable boolean configureFileFeedButtonEnabled + @Observable boolean downloadFeedItemButtonEnabled + @Observable boolean viewFeedItemCommentButtonEnabled + @Observable boolean viewFeedItemCertificatesButtonEnabled @Observable boolean subscribeButtonEnabled @Observable boolean markNeutralFromTrustedButtonEnabled @Observable boolean markDistrustedButtonEnabled @@ -118,6 +135,7 @@ class MainFrameModel { @Observable boolean downloadsPaneButtonEnabled @Observable boolean uploadsPaneButtonEnabled @Observable boolean monitorPaneButtonEnabled + @Observable boolean feedsPaneButtonEnabled @Observable boolean trustPaneButtonEnabled @Observable boolean chatPaneButtonEnabled @@ -125,7 +143,7 @@ class MainFrameModel { @Observable Downloader downloader - private final Set downloadInfoHashes = new HashSet<>() + private final Set downloadInfoHashes = new ConcurrentHashSet<>() @Observable volatile Core core @@ -215,6 +233,10 @@ class MainFrameModel { core.eventBus.register(TrustSubscriptionUpdatedEvent.class, this) core.eventBus.register(SearchEvent.class, this) core.eventBus.register(CertificateCreatedEvent.class, this) + core.eventBus.register(FeedLoadedEvent.class, this) + core.eventBus.register(FeedFetchEvent.class, this) + core.eventBus.register(FeedItemFetchedEvent.class, this) + core.eventBus.register(UIFeedConfigurationEvent.class, this) core.muOptions.watchedKeywords.each { core.eventBus.publish(new ContentControlEvent(term : it, regex: false, add: true)) @@ -253,11 +275,13 @@ class MainFrameModel { distrusted.addAll(core.trustService.bad.values()) resumeButtonText = "Retry" + publishButtonText = "Publish" searchesPaneButtonEnabled = false downloadsPaneButtonEnabled = true uploadsPaneButtonEnabled = true monitorPaneButtonEnabled = true + feedsPaneButtonEnabled = true trustPaneButtonEnabled = true chatPaneButtonEnabled = true @@ -651,4 +675,41 @@ class MainFrameModel { int requests boolean finished } + + void onFeedLoadedEvent(FeedLoadedEvent e) { + runInsideUIAsync { + feeds << e.feed + view.refreshFeeds() + } + } + + void onFeedFetchEvent(FeedFetchEvent e) { + runInsideUIAsync { + view.refreshFeeds() + } + } + + void onUIFeedConfigurationEvent(UIFeedConfigurationEvent e) { + if (!e.newFeed) + return + runInsideUIAsync { + if (feeds.contains(e.feed)) + return + feeds << e.feed + view.refreshFeeds() + } + } + + void onFeedItemFetchedEvent(FeedItemFetchedEvent e) { + Feed feed = core.feedManager.getFeed(e.item.getPublisher()) + if (feed == null || !feed.isAutoDownload()) + return + if (!canDownload(e.item.getInfoHash())) + return + if (core.fileManager.isShared(e.item.getInfoHash())) + return + + File target = new File(core.getMuOptions().getDownloadLocation(), e.item.getName()) + core.eventBus.publish(new UIDownloadFeedItemEvent(item : e.item, target : target, sequential : feed.isSequential())) + } } \ No newline at end of file diff --git a/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy b/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy index 6e0c6eaf..b0f62bc6 100644 --- a/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy +++ b/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy @@ -50,6 +50,15 @@ class OptionsModel { @Observable String inBw @Observable String outBw + // feed options + @Observable boolean fileFeed + @Observable boolean advertiseFeed + @Observable boolean autoPublishSharedFiles + @Observable boolean defaultFeedAutoDownload + @Observable String defaultFeedItemsToKeep + @Observable boolean defaultFeedSequential + @Observable String defaultFeedUpdateInterval + // trust options @Observable boolean onlyTrusted @Observable boolean searchExtraHop @@ -105,6 +114,14 @@ class OptionsModel { inBw = String.valueOf(settings.inBw) outBw = String.valueOf(settings.outBw) } + + fileFeed = settings.fileFeed + advertiseFeed = settings.advertiseFeed + autoPublishSharedFiles = settings.autoPublishSharedFiles + defaultFeedAutoDownload = settings.defaultFeedAutoDownload + defaultFeedItemsToKeep = String.valueOf(settings.defaultFeedItemsToKeep) + defaultFeedSequential = settings.defaultFeedSequential + defaultFeedUpdateInterval = String.valueOf(settings.defaultFeedUpdateInterval) onlyTrusted = !settings.allowUntrusted() searchExtraHop = settings.searchExtraHop diff --git a/gui/griffon-app/models/com/muwire/gui/SearchTabModel.groovy b/gui/griffon-app/models/com/muwire/gui/SearchTabModel.groovy index 6e7144aa..2ad0a2ee 100644 --- a/gui/griffon-app/models/com/muwire/gui/SearchTabModel.groovy +++ b/gui/griffon-app/models/com/muwire/gui/SearchTabModel.groovy @@ -25,6 +25,7 @@ class SearchTabModel { @Observable boolean viewCommentActionEnabled @Observable boolean viewCertificatesActionEnabled @Observable boolean chatActionEnabled + @Observable boolean subscribeActionEnabled @Observable boolean groupedByFile Core core diff --git a/gui/griffon-app/views/com/muwire/gui/FeedConfigurationView.groovy b/gui/griffon-app/views/com/muwire/gui/FeedConfigurationView.groovy new file mode 100644 index 00000000..9cc23d02 --- /dev/null +++ b/gui/griffon-app/views/com/muwire/gui/FeedConfigurationView.groovy @@ -0,0 +1,73 @@ +package com.muwire.gui + +import griffon.core.artifact.GriffonView +import griffon.inject.MVCMember +import griffon.metadata.ArtifactProviderFor + +import javax.swing.JDialog +import javax.swing.SwingConstants + +import java.awt.BorderLayout +import java.awt.GridBagConstraints +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent + +import javax.annotation.Nonnull + +@ArtifactProviderFor(GriffonView) +class FeedConfigurationView { + @MVCMember @Nonnull + FactoryBuilderSupport builder + @MVCMember @Nonnull + FeedConfigurationModel model + + def dialog + def p + def mainFrame + + def autoDownloadCheckbox + def sequentialCheckbox + def itemsToKeepField + def updateIntervalField + + void initUI() { + mainFrame = application.windowManager.findWindow("main-frame") + dialog = new JDialog(mainFrame, "Feed Configuration", true) + dialog.setResizable(false) + + p = builder.panel { + borderLayout() + panel (constraints : BorderLayout.NORTH) { + label("Configuration for feed " + model.feed.getPublisher().getHumanReadableName()) + } + panel (constraints : BorderLayout.CENTER) { + gridBagLayout() + label(text : "Automatically download files from feed", constraints : gbc(gridx: 0, gridy : 0, anchor : GridBagConstraints.LINE_START, weightx: 100)) + autoDownloadCheckbox = checkBox(selected : bind {model.autoDownload}, constraints : gbc(gridx: 1, gridy : 0, anchor : GridBagConstraints.LINE_END)) + label(text : "Download files from feed sequentially", constraints : gbc(gridx: 0, gridy : 1, anchor : GridBagConstraints.LINE_START, weightx: 100)) + sequentialCheckbox = checkBox(selected : bind {model.sequential}, constraints : gbc(gridx: 1, gridy : 1, anchor : GridBagConstraints.LINE_END)) + label(text : "Feed items to store on disk (-1 means unlimited)", constraints : gbc(gridx: 0, gridy : 2, anchor : GridBagConstraints.LINE_START, weightx: 100)) + itemsToKeepField = textField(text : bind {model.itemsToKeep}, constraints:gbc(gridx :1, gridy:2, anchor : GridBagConstraints.LINE_END)) + label(text : "Feed refresh frequency in minutes", constraints : gbc(gridx: 0, gridy : 3, anchor : GridBagConstraints.LINE_START, weightx: 100)) + updateIntervalField = textField(text : bind {model.updateInterval}, constraints:gbc(gridx :1, gridy:3, anchor : GridBagConstraints.LINE_END)) + } + panel (constraints : BorderLayout.SOUTH) { + button(text : "Save", saveAction) + button(text : "Cancel", cancelAction) + } + } + } + + void mvcGroupInit(Map args) { + dialog.getContentPane().add(p) + dialog.pack() + dialog.setLocationRelativeTo(mainFrame) + dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE) + dialog.addWindowListener(new WindowAdapter() { + public void windowClosed(WindowEvent e) { + mvcGroup.destroy() + } + }) + dialog.show() + } +} \ No newline at end of file diff --git a/gui/griffon-app/views/com/muwire/gui/FetchCertificatesView.groovy b/gui/griffon-app/views/com/muwire/gui/FetchCertificatesView.groovy index f2dcbdb9..e0f6689d 100644 --- a/gui/griffon-app/views/com/muwire/gui/FetchCertificatesView.groovy +++ b/gui/griffon-app/views/com/muwire/gui/FetchCertificatesView.groovy @@ -38,7 +38,7 @@ class FetchCertificatesView { void initUI() { int rowHeight = application.context.get("row-height") mainFrame = application.windowManager.findWindow("main-frame") - dialog = new JDialog(mainFrame, model.result.name, true) + dialog = new JDialog(mainFrame, model.name, true) dialog.setResizable(true) p = builder.panel { diff --git a/gui/griffon-app/views/com/muwire/gui/MainFrameView.groovy b/gui/griffon-app/views/com/muwire/gui/MainFrameView.groovy index 6949a5d5..adb891b8 100644 --- a/gui/griffon-app/views/com/muwire/gui/MainFrameView.groovy +++ b/gui/griffon-app/views/com/muwire/gui/MainFrameView.groovy @@ -39,6 +39,9 @@ import com.muwire.core.InfoHash import com.muwire.core.MuWireSettings import com.muwire.core.SharedFile import com.muwire.core.download.Downloader +import com.muwire.core.filefeeds.Feed +import com.muwire.core.filefeeds.FeedFetchStatus +import com.muwire.core.filefeeds.FeedItem import com.muwire.core.files.FileSharedEvent import com.muwire.core.trust.RemoteTrustList import java.awt.BorderLayout @@ -76,6 +79,8 @@ class MainFrameView { def lastSharedSortEvent def trustTablesSortEvents = [:] def expansionListener = new TreeExpansions() + def lastFeedsSortEvent + def lastFeedItemsSortEvent UISettings settings @@ -151,6 +156,7 @@ class MainFrameView { button(text: "Uploads", enabled : bind{model.uploadsPaneButtonEnabled}, actionPerformed : showUploadsWindow) if (settings.showMonitor) button(text: "Monitor", enabled: bind{model.monitorPaneButtonEnabled},actionPerformed : showMonitorWindow) + button(text: "Feeds", enabled: bind {model.feedsPaneButtonEnabled}, actionPerformed : showFeedsWindow) button(text: "Trust", enabled:bind{model.trustPaneButtonEnabled},actionPerformed : showTrustWindow) button(text: "Chat", enabled : bind{model.chatPaneButtonEnabled}, actionPerformed : showChatWindow) } @@ -292,6 +298,7 @@ class MainFrameView { Core core = application.context.get("core") core.certificateManager.hasLocalCertificate(new InfoHash(it.getRoot())) }) + closureColumn(header : "Published", preferredWidth : 50, type : Boolean, read : {row -> row.isPublished()}) closureColumn(header : "Search Hits", preferredWidth: 50, type : Integer, read : {it.getHits()}) closureColumn(header : "Downloaders", preferredWidth: 50, type : Integer, read : {it.getDownloaders().size()}) } @@ -316,9 +323,11 @@ class MainFrameView { radioButton(text : "Table", selected : false, buttonGroup : sharedViewType, actionPerformed : showSharedFilesTable) } panel { - button(text : "Share", actionPerformed : shareFiles) - button(text : "Add Comment", enabled : bind {model.addCommentButtonEnabled}, addCommentAction) - button(text : "Certify", enabled : bind {model.addCommentButtonEnabled}, issueCertificateAction) + gridBagLayout() + button(text : "Share", constraints : gbc(gridx: 0), actionPerformed : shareFiles) + button(text : "Add Comment", enabled : bind {model.addCommentButtonEnabled}, constraints : gbc(gridx: 1), addCommentAction) + button(text : "Certify", enabled : bind {model.addCommentButtonEnabled}, constraints : gbc(gridx: 2), issueCertificateAction) + button(text : bind {model.publishButtonText}, enabled : bind {model.publishButtonEnabled}, constraints : gbc(gridx:3), publishAction) } panel { panel { @@ -425,6 +434,56 @@ class MainFrameView { } } } + panel(constraints : "feeds window") { + gridLayout(rows : 2, cols : 1) + panel { + borderLayout() + panel (constraints : BorderLayout.NORTH) { + label(text: "Subscriptions") + } + scrollPane(constraints : BorderLayout.CENTER) { + table(id : "feeds-table", autoCreateRowSorter : true, rowHeight : rowHeight) { + tableModel(list : model.feeds) { + closureColumn(header : "Publisher", preferredWidth: 350, type : String, read : {it.getPublisher().getHumanReadableName()}) + closureColumn(header : "Files", preferredWidth: 10, type : Integer, read : {model.core.feedManager.getFeedItems(it.getPublisher()).size()}) + closureColumn(header : "Last Updated", type : Long, read : {it.getLastUpdated()}) + closureColumn(header : "Status", preferredWidth: 10, type : String, read : {it.getStatus()}) + } + } + } + panel (constraints : BorderLayout.SOUTH) { + button(text : "Update", enabled : bind {model.updateFileFeedButtonEnabled}, updateFileFeedAction) + button(text : "Unsubscribe", enabled : bind {model.unsubscribeFileFeedButtonEnabled}, unsubscribeFileFeedAction) + button(text : "Configure", enabled : bind {model.configureFileFeedButtonEnabled}, configureFileFeedAction) + } + } + panel { + borderLayout() + panel (constraints : BorderLayout.NORTH) { + label(text : "Published Files") + } + scrollPane(constraints : BorderLayout.CENTER) { + table(id : "feed-items-table", autoCreateRowSorter : true, rowHeight : rowHeight) { + tableModel(list : model.feedItems) { + closureColumn(header : "Name", preferredWidth: 350, type : String, read : {it.getName()}) + closureColumn(header : "Size", preferredWidth: 10, type : Long, read : {it.getSize()}) + closureColumn(header : "Comment", preferredWidth: 10, type : Boolean, read : {it.getComment() != null}) + closureColumn(header : "Certificates", preferredWidth: 10, type : Integer, read : {it.getCertificates()}) + closureColumn(header : "Downloaded", preferredWidth: 10, type : Boolean, read : { + InfoHash ih = it.getInfoHash() + model.core.fileManager.isShared(ih) + }) + closureColumn(header: "Date", type : Long, read : {it.getTimestamp()}) + } + } + } + panel(constraints : BorderLayout.SOUTH) { + button(text : "Download", enabled : bind {model.downloadFeedItemButtonEnabled}, downloadFeedItemAction) + button(text : "View Comment", enabled : bind {model.viewFeedItemCommentButtonEnabled}, viewFeedItemCommentAction) + button(text : "View Certificates", enabled : bind {model.viewFeedItemCertificatesButtonEnabled}, viewFeedItemCertificatesAction ) + } + } + } panel(constraints : "trust window") { gridLayout(rows : 2, cols : 1) panel { @@ -682,14 +741,30 @@ class MainFrameView { if (selectedFiles == null || selectedFiles.isEmpty()) return model.addCommentButtonEnabled = true + model.publishButtonEnabled = true + boolean unpublish = true + selectedFiles.each { + unpublish &= it.isPublished() + } + model.publishButtonText = unpublish ? "Unpublish" : "Publish" }) + def sharedFilesTree = builder.getVariable("shared-files-tree") sharedFilesTree.addMouseListener(sharedFilesMouseListener) sharedFilesTree.addTreeSelectionListener({ def selectedNode = sharedFilesTree.getLastSelectedPathComponent() model.addCommentButtonEnabled = selectedNode != null - + model.publishButtonEnabled = selectedNode != null + + def selectedFiles = selectedSharedFiles() + if (selectedFiles == null || selectedFiles.isEmpty()) + return + boolean unpublish = true + selectedFiles.each { + unpublish &= it.isPublished() + } + model.publishButtonText = unpublish ? "Unpublish" : "Publish" }) sharedFilesTree.addTreeExpansionListener(expansionListener) @@ -745,6 +820,98 @@ class MainFrameView { } }) + // feeds table + def feedsTable = builder.getVariable("feeds-table") + feedsTable.rowSorter.addRowSorterListener({evt -> lastFeedsSortEvent = evt}) + feedsTable.rowSorter.setSortsOnUpdates(true) + feedsTable.setDefaultRenderer(Integer.class, centerRenderer) + feedsTable.setDefaultRenderer(Long.class, new DateRenderer()) + selectionModel = feedsTable.getSelectionModel() + selectionModel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION) + selectionModel.addListSelectionListener({ + Feed selectedFeed = selectedFeed() + if (selectedFeed == null) { + model.updateFileFeedButtonEnabled = false + model.unsubscribeFileFeedButtonEnabled = false + model.configureFileFeedButtonEnabled = false + return + } + + model.unsubscribeFileFeedButtonEnabled = true + model.configureFileFeedButtonEnabled = true + model.updateFileFeedButtonEnabled = !selectedFeed.getStatus().isActive() + + def items = model.core.feedManager.getFeedItems(selectedFeed.getPublisher()) + model.feedItems.clear() + model.feedItems.addAll(items) + + def feedItemsTable = builder.getVariable("feed-items-table") + int selectedItemRow = feedItemsTable.getSelectedRow() + feedItemsTable.model.fireTableDataChanged() + if (selectedItemRow >= 0 && selectedItemRow < items.size()) + feedItemsTable.selectionModel.setSelectionInterval(selectedItemRow, selectedItemRow) + }) + feedsTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if(e.isPopupTrigger() || e.getButton() == MouseEvent.BUTTON3) + showFeedsPopupMenu(e) + } + @Override + public void mouseReleased(MouseEvent e) { + if(e.isPopupTrigger() || e.getButton() == MouseEvent.BUTTON3) + showFeedsPopupMenu(e) + } + }) + + + // feed items table + def feedItemsTable = builder.getVariable("feed-items-table") + feedItemsTable.rowSorter.addRowSorterListener({evt -> lastFeedItemsSortEvent = evt}) + feedItemsTable.rowSorter.setSortsOnUpdates(true) + feedItemsTable.setDefaultRenderer(Integer.class, centerRenderer) + feedItemsTable.columnModel.getColumn(1).setCellRenderer(new SizeRenderer()) + feedItemsTable.columnModel.getColumn(5).setCellRenderer(new DateRenderer()) + + selectionModel = feedItemsTable.getSelectionModel() + selectionModel.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION) + selectionModel.addListSelectionListener({ + List selectedItems = selectedFeedItems() + if (selectedItems == null || selectedItems.isEmpty()) { + model.downloadFeedItemButtonEnabled = false + model.viewFeedItemCommentButtonEnabled = false + model.viewFeedItemCertificatesButtonEnabled = false + return + } + model.downloadFeedItemButtonEnabled = true + model.viewFeedItemCommentButtonEnabled = false + model.viewFeedItemCertificatesButtonEnabled = false + if (selectedItems.size() == 1) { + FeedItem item = selectedItems.get(0) + model.viewFeedItemCommentButtonEnabled = item.getComment() != null + model.viewFeedItemCertificatesButtonEnabled = item.getCertificates() > 0 + } + }) + feedItemsTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + List selectedItems = selectedFeedItems() + if (e.isPopupTrigger() || e.getButton() == MouseEvent.BUTTON3) + showFeedItemsPopupMenu(e) + else if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 2 && + selectedItems != null && selectedItems.size() == 1 && + model.canDownload(selectedItems.get(0).getInfoHash())) { + mvcGroup.controller.downloadFeedItem() + } + } + + @Override + public void mouseReleased(MouseEvent e) { + if (e.isPopupTrigger() || e.getButton() == MouseEvent.BUTTON3) + showFeedItemsPopupMenu(e) + } + }) + // subscription table def subscriptionTable = builder.getVariable("subscription-table") subscriptionTable.setDefaultRenderer(Integer.class, centerRenderer) @@ -1007,6 +1174,52 @@ class MainFrameView { showPopupMenu(menu, e) } + void showFeedsPopupMenu(MouseEvent e) { + Feed feed = selectedFeed() + if (feed == null) + return + JPopupMenu menu = new JPopupMenu() + if (model.updateFileFeedButtonEnabled) { + JMenuItem update = new JMenuItem("Update") + update.addActionListener({mvcGroup.controller.updateFileFeed()}) + menu.add(update) + } + + JMenuItem unsubscribe = new JMenuItem("Unsubscribe") + unsubscribe.addActionListener({mvcGroup.controller.unsubscribeFileFeed()}) + menu.add(unsubscribe) + + JMenuItem configure = new JMenuItem("Configure") + configure.addActionListener({mvcGroup.controller.configureFileFeed()}) + menu.add(configure) + + showPopupMenu(menu,e) + } + + void showFeedItemsPopupMenu(MouseEvent e) { + List items = selectedFeedItems() + if (items == null || items.isEmpty()) + return + // TODO: finish + JPopupMenu menu = new JPopupMenu() + if (model.downloadFeedItemButtonEnabled) { + JMenuItem download = new JMenuItem("Download") + download.addActionListener({mvcGroup.controller.downloadFeedItem()}) + menu.add(download) + } + if (model.viewFeedItemCommentButtonEnabled) { + JMenuItem viewComment = new JMenuItem("View Comment") + viewComment.addActionListener({mvcGroup.controller.viewFeedItemComment()}) + menu.add(viewComment) + } + if (model.viewFeedItemCertificatesButtonEnabled) { + JMenuItem viewCertificates = new JMenuItem("View Certificates") + viewCertificates.addActionListener({mvcGroup.controller.viewFeedItemCertificates()}) + menu.add(viewCertificates) + } + showPopupMenu(menu, e) + } + def selectedUploader() { def uploadsTable = builder.getVariable("uploads-table") int selectedRow = uploadsTable.getSelectedRow() @@ -1057,6 +1270,7 @@ class MainFrameView { model.downloadsPaneButtonEnabled = true model.uploadsPaneButtonEnabled = true model.monitorPaneButtonEnabled = true + model.feedsPaneButtonEnabled = true model.trustPaneButtonEnabled = true model.chatPaneButtonEnabled = true chatNotificator.mainWindowDeactivated() @@ -1069,6 +1283,7 @@ class MainFrameView { model.downloadsPaneButtonEnabled = false model.uploadsPaneButtonEnabled = true model.monitorPaneButtonEnabled = true + model.feedsPaneButtonEnabled = true model.trustPaneButtonEnabled = true model.chatPaneButtonEnabled = true chatNotificator.mainWindowDeactivated() @@ -1081,6 +1296,7 @@ class MainFrameView { model.downloadsPaneButtonEnabled = true model.uploadsPaneButtonEnabled = false model.monitorPaneButtonEnabled = true + model.feedsPaneButtonEnabled = true model.trustPaneButtonEnabled = true model.chatPaneButtonEnabled = true chatNotificator.mainWindowDeactivated() @@ -1093,6 +1309,20 @@ class MainFrameView { model.downloadsPaneButtonEnabled = true model.uploadsPaneButtonEnabled = true model.monitorPaneButtonEnabled = false + model.feedsPaneButtonEnabled = true + model.trustPaneButtonEnabled = true + model.chatPaneButtonEnabled = true + chatNotificator.mainWindowDeactivated() + } + + def showFeedsWindow = { + def cardsPanel = builder.getVariable("cards-panel") + cardsPanel.getLayout().show(cardsPanel,"feeds window") + model.searchesPaneButtonEnabled = true + model.downloadsPaneButtonEnabled = true + model.uploadsPaneButtonEnabled = true + model.monitorPaneButtonEnabled = true + model.feedsPaneButtonEnabled = false model.trustPaneButtonEnabled = true model.chatPaneButtonEnabled = true chatNotificator.mainWindowDeactivated() @@ -1105,6 +1335,7 @@ class MainFrameView { model.downloadsPaneButtonEnabled = true model.uploadsPaneButtonEnabled = true model.monitorPaneButtonEnabled = true + model.feedsPaneButtonEnabled = true model.trustPaneButtonEnabled = false model.chatPaneButtonEnabled = true chatNotificator.mainWindowDeactivated() @@ -1117,6 +1348,7 @@ class MainFrameView { model.downloadsPaneButtonEnabled = true model.uploadsPaneButtonEnabled = true model.monitorPaneButtonEnabled = true + model.feedsPaneButtonEnabled = true model.trustPaneButtonEnabled = true model.chatPaneButtonEnabled = false chatNotificator.mainWindowActivated() @@ -1173,6 +1405,43 @@ class MainFrameView { builder.getVariable("shared-files-table").model.fireTableDataChanged() } + public void refreshFeeds() { + JTable feedsTable = builder.getVariable("feeds-table") + int selectedFeed = feedsTable.getSelectedRow() + feedsTable.model.fireTableDataChanged() + if (selectedFeed >= 0) + feedsTable.selectionModel.setSelectionInterval(selectedFeed, selectedFeed) + + JTable feedItemsTable = builder.getVariable("feed-items-table") + feedItemsTable.model.fireTableDataChanged() + } + + Feed selectedFeed() { + JTable feedsTable = builder.getVariable("feeds-table") + int row = feedsTable.getSelectedRow() + if (row < 0) + return null + if (lastFeedsSortEvent != null) + row = feedsTable.rowSorter.convertRowIndexToModel(row) + model.feeds[row] + } + + List selectedFeedItems() { + JTable feedItemsTable = builder.getVariable("feed-items-table") + int [] selectedRows = feedItemsTable.getSelectedRows() + if (selectedRows.length == 0) + return null + List rv = new ArrayList<>() + if (lastFeedItemsSortEvent != null) { + for (int i = 0; i < selectedRows.length; i++) { + selectedRows[i] = feedItemsTable.rowSorter.convertRowIndexToModel(selectedRows[i]) + } + } + for (int selectedRow : selectedRows) + rv.add(model.feedItems[selectedRow]) + rv + } + private void closeApplication() { Core core = application.getContext().get("core") diff --git a/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy b/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy index 3ab4464a..5054232a 100644 --- a/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy +++ b/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy @@ -32,6 +32,7 @@ class OptionsView { def i def u def bandwidth + def feed def trust def chat @@ -66,6 +67,14 @@ class OptionsView { def inBwField def outBwField + + def fileFeedCheckbox + def advertiseFeedCheckbox + def autoPublishSharedFilesCheckbox + def defaultFeedAutoDownloadCheckbox + def defaultFeedItemsToKeepField + def defaultFeedSequentialCheckbox + def defaultFeedUpdateIntervalField def allowUntrustedCheckbox def searchExtraHopCheckbox @@ -257,6 +266,32 @@ class OptionsView { } panel(constraints : gbc(gridx: 0, gridy: 1, weighty: 100)) } + feed = builder.panel { + gridBagLayout() + panel (border : titledBorder(title : "General Feed Settings", border : etchedBorder(), titlePosition : TitledBorder.TOP), + constraints : gbc(gridx : 0, gridy : 0, fill : GridBagConstraints.HORIZONTAL, weightx: 100)) { + gridBagLayout() + label(text : "Enable file feed", constraints : gbc(gridx: 0, gridy : 0, anchor : GridBagConstraints.LINE_START, weightx: 100)) + fileFeedCheckbox = checkBox(selected : bind {model.fileFeed}, constraints : gbc(gridx: 1, gridy : 0, anchor : GridBagConstraints.LINE_END)) + label(text : "Advertise feed in search results", constraints : gbc(gridx: 0, gridy : 1, anchor : GridBagConstraints.LINE_START, weightx: 100)) + advertiseFeedCheckbox = checkBox(selected : bind {model.advertiseFeed}, constraints : gbc(gridx: 1, gridy : 1, anchor : GridBagConstraints.LINE_END)) + label(text : "Automatically publish shared files", constraints : gbc(gridx: 0, gridy : 2, anchor : GridBagConstraints.LINE_START, weightx: 100)) + autoPublishSharedFilesCheckbox = checkBox(selected : bind {model.autoPublishSharedFiles}, constraints : gbc(gridx: 1, gridy : 2, anchor : GridBagConstraints.LINE_END)) + } + panel (border : titledBorder(title : "Default Settings For New Feeds", border : etchedBorder(), titlePosition : TitledBorder.TOP), + constraints : gbc(gridx : 0, gridy : 1, fill : GridBagConstraints.HORIZONTAL, weightx: 100)) { + gridBagLayout() + label(text : "Automatically download files from feed", constraints : gbc(gridx: 0, gridy : 0, anchor : GridBagConstraints.LINE_START, weightx: 100)) + defaultFeedAutoDownloadCheckbox = checkBox(selected : bind {model.defaultFeedAutoDownload}, constraints : gbc(gridx: 1, gridy : 0, anchor : GridBagConstraints.LINE_END)) + label(text : "Download files from feed sequentially", constraints : gbc(gridx: 0, gridy : 1, anchor : GridBagConstraints.LINE_START, weightx: 100)) + defaultFeedSequentialCheckbox = checkBox(selected : bind {model.defaultFeedSequential}, constraints : gbc(gridx: 1, gridy : 1, anchor : GridBagConstraints.LINE_END)) + label(text : "Feed items to store on disk (-1 means unlimited)", constraints : gbc(gridx: 0, gridy : 2, anchor : GridBagConstraints.LINE_START, weightx: 100)) + defaultFeedItemsToKeepField = textField(text : bind {model.defaultFeedItemsToKeep}, constraints:gbc(gridx :1, gridy:2, anchor : GridBagConstraints.LINE_END)) + label(text : "Feed refresh frequency in minutes", constraints : gbc(gridx: 0, gridy : 3, anchor : GridBagConstraints.LINE_START, weightx: 100)) + defaultFeedUpdateIntervalField = textField(text : bind {model.defaultFeedUpdateInterval}, constraints:gbc(gridx :1, gridy:3, anchor : GridBagConstraints.LINE_END)) + } + panel(constraints : gbc(gridx: 0, gridy : 2, weighty: 100)) + } trust = builder.panel { gridBagLayout() panel (border : titledBorder(title : "Trust Settings", border : etchedBorder(), titlePosition : TitledBorder.TOP), @@ -311,6 +346,7 @@ class OptionsView { if (core.router != null) { tabbedPane.addTab("Bandwidth", bandwidth) } + tabbedPane.addTab("Feed", feed) tabbedPane.addTab("Trust", trust) tabbedPane.addTab("Chat", chat) diff --git a/gui/griffon-app/views/com/muwire/gui/SearchTabView.groovy b/gui/griffon-app/views/com/muwire/gui/SearchTabView.groovy index c83e2e88..ee3d40f7 100644 --- a/gui/griffon-app/views/com/muwire/gui/SearchTabView.groovy +++ b/gui/griffon-app/views/com/muwire/gui/SearchTabView.groovy @@ -74,6 +74,7 @@ class SearchTabView { closureColumn(header : "Sender", preferredWidth : 500, type: String, read : {row -> row.getHumanReadableName()}) closureColumn(header : "Results", preferredWidth : 20, type: Integer, read : {row -> model.sendersBucket[row].size()}) closureColumn(header : "Browse", preferredWidth : 20, type: Boolean, read : {row -> model.sendersBucket[row].first().browse}) + closureColumn(header : "Feed", preferredWidth : 20, type : Boolean, read : {row -> model.sendersBucket[row].first().feed}) closureColumn(header : "Chat", preferredWidth : 20, type : Boolean, read : {row -> model.sendersBucket[row].first().chat}) closureColumn(header : "Trust", preferredWidth : 50, type: String, read : { row -> model.core.trustService.getLevel(row.destination).toString() @@ -85,6 +86,7 @@ class SearchTabView { gridLayout(rows: 1, cols : 2) panel (border : etchedBorder()){ button(text : "Browse Host", enabled : bind {model.browseActionEnabled}, browseAction) + button(text : "Subscribe", enabled : bind {model.subscribeActionEnabled}, subscribeAction) button(text : "Chat", enabled : bind{model.chatActionEnabled}, chatAction) } panel (border : etchedBorder()){ @@ -156,6 +158,14 @@ class SearchTabView { } count }) + closureColumn(header : "Feeds", preferredWidth : 20, type : Integer, read : { + int count = 0 + model.hashBucket[it].each { + if (it.feed) + count++ + } + count + }) closureColumn(header : "Chat Hosts", preferredWidth : 20, type : Integer, read : { int count = 0 model.hashBucket[it].each { @@ -187,6 +197,7 @@ class SearchTabView { tableModel(list : model.senders2) { closureColumn(header : "Sender", preferredWidth : 350, type : String, read : {it.sender.getHumanReadableName()}) closureColumn(header : "Browse", preferredWidth : 20, type : Boolean, read : {it.browse}) + closureColumn(header : "Feed", preferredWidth : 20, type: Boolean, read : {it.feed}) closureColumn(header : "Chat", preferredWidth : 20, type : Boolean, read : {it.chat}) closureColumn(header : "Comment", preferredWidth : 20, type : Boolean, read : {it.comment != null}) closureColumn(header : "Certificates", preferredWidth : 20, type: Integer, read : {it.certificates}) @@ -200,6 +211,7 @@ class SearchTabView { gridLayout(rows : 1, cols : 2) panel (border : etchedBorder()) { button(text : "Browse Host", enabled : bind {model.browseActionEnabled}, browseAction) + button(text : "Subscribe", enabled : bind {model.subscribeActionEnabled}, subscribeAction) button(text : "Chat", enabled : bind{model.chatActionEnabled}, chatAction) button(text : "View Comment", enabled : bind {model.viewCommentActionEnabled}, showCommentAction) button(text : "View Certificates", enabled : bind {model.viewCertificatesActionEnabled}, viewCertificatesAction) @@ -308,6 +320,7 @@ class SearchTabView { if (result == null) { model.viewCommentActionEnabled = false model.viewCertificatesActionEnabled = false + model.subscribeActionEnabled = false return } else { model.viewCommentActionEnabled = result.comment != null @@ -326,12 +339,14 @@ class SearchTabView { if (row < 0) { model.trustButtonsEnabled = false model.browseActionEnabled = false - model.chatActionEnabled = false + model.subscribeActionEnabled = false return } else { Persona sender = model.senders[row] model.browseActionEnabled = model.sendersBucket[sender].first().browse model.chatActionEnabled = model.sendersBucket[sender].first().chat + model.subscribeActionEnabled = model.sendersBucket[sender].first().feed && + model.core.feedManager.getFeed(sender) == null model.trustButtonsEnabled = true model.results.clear() model.results.addAll(model.sendersBucket[sender]) @@ -386,16 +401,19 @@ class SearchTabView { if (row < 0 || model.senders2[row] == null) { model.browseActionEnabled = false model.chatActionEnabled = false + model.subscribeActionEnabled = false model.viewCertificatesActionEnabled = false model.trustButtonsEnabled = false model.viewCommentActionEnabled = false return } - model.browseActionEnabled = model.senders2[row].browse - model.chatActionEnabled = model.senders2[row].chat + UIResultEvent e = model.senders2[row] + model.browseActionEnabled = e.browse + model.chatActionEnabled = e.chat + model.subscribeActionEnabled = e.feed && model.core.feedManager.getFeed(e.getSender()) == null model.trustButtonsEnabled = true - model.viewCommentActionEnabled = model.senders2[row].comment != null - model.viewCertificatesActionEnabled = model.senders2[row].certificates > 0 + model.viewCommentActionEnabled = e.comment != null + model.viewCertificatesActionEnabled = e.certificates > 0 }) if (settings.groupByFile)