mirror of
https://github.com/bancika/diy-layout-creator
synced 2026-11-13 02:32:09 +10:00
Cloud project scraper added
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.swing.plugins.cloud.sync;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Configuration of {@link CloudSyncJob}. It carries login tokens, so the file it is read from is
|
||||
* kept out of version control.
|
||||
*
|
||||
* @param serviceUrl optional override of the cloud API base URL
|
||||
* @param storageDir where downloaded files, thumbnails and sync state are kept, resolved against
|
||||
* the directory of the configuration file when relative
|
||||
* @param users accounts whose pages are synced
|
||||
*/
|
||||
public record CloudSyncConfig(String serviceUrl, String storageDir, List<UserConfig> users) {
|
||||
|
||||
public static final String DEFAULT_SERVICE_URL = "http://www.diy-fever.com/diylc/api/v1";
|
||||
public static final String DEFAULT_STORAGE_DIR = "storage";
|
||||
|
||||
public static CloudSyncConfig load(File file) throws IOException {
|
||||
return new ObjectMapper().readValue(file, CloudSyncConfig.class);
|
||||
}
|
||||
|
||||
public String serviceUrlOrDefault() {
|
||||
return serviceUrl == null || serviceUrl.isBlank() ? DEFAULT_SERVICE_URL : serviceUrl;
|
||||
}
|
||||
|
||||
public File resolveStorageDir(File configFile) {
|
||||
File dir =
|
||||
new File(storageDir == null || storageDir.isBlank() ? DEFAULT_STORAGE_DIR : storageDir);
|
||||
return dir.isAbsolute() ? dir
|
||||
: new File(configFile.getAbsoluteFile().getParentFile(), dir.getPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* The cloud API authenticates by username (or email), token and machine ID; {@code userId} only
|
||||
* names the user's storage folder.
|
||||
*/
|
||||
public record UserConfig(int userId, String username, String machineId, String token,
|
||||
String category, String keywords, List<PageConfig> pages) {
|
||||
|
||||
public String categoryFor(PageConfig page) {
|
||||
return page.category() == null ? category : page.category();
|
||||
}
|
||||
|
||||
public String keywordsFor(PageConfig page) {
|
||||
String result = page.keywords() == null ? keywords : page.keywords();
|
||||
return result == null ? "" : result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page to scrape for links to .diy files. A bare URL string in the JSON is accepted as a page
|
||||
* that inherits category and keywords from its user.
|
||||
*/
|
||||
public record PageConfig(String url, String category, String keywords) {
|
||||
|
||||
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
|
||||
public PageConfig {}
|
||||
|
||||
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
|
||||
public static PageConfig of(String url) {
|
||||
return new PageConfig(url, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.swing.plugins.cloud.sync;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.log4j.BasicConfigurator;
|
||||
import org.apache.log4j.Level;
|
||||
import org.apache.log4j.LogManager;
|
||||
import org.apache.log4j.Logger;
|
||||
|
||||
import com.diyfever.httpproxy.PhpFlatProxy;
|
||||
import com.diyfever.httpproxy.ProxyFactory;
|
||||
|
||||
import org.diylc.appframework.miscutils.ConfigurationManager;
|
||||
import org.diylc.appframework.miscutils.InMemoryConfigurationManager;
|
||||
import org.diylc.common.DummyView;
|
||||
import org.diylc.core.IView;
|
||||
import org.diylc.plugins.cloud.model.IServiceAPI;
|
||||
import org.diylc.plugins.cloud.model.ProjectEntity;
|
||||
import org.diylc.plugins.cloud.service.CloudException;
|
||||
import org.diylc.presenter.Presenter;
|
||||
import org.diylc.swing.plugins.cloud.ThumbnailGenerator;
|
||||
import org.diylc.swing.plugins.cloud.sync.CloudSyncConfig.PageConfig;
|
||||
import org.diylc.swing.plugins.cloud.sync.CloudSyncConfig.UserConfig;
|
||||
import org.diylc.swing.plugins.cloud.sync.SyncState.SyncedFile;
|
||||
|
||||
/**
|
||||
* Command-line job that mirrors .diy files published on users' own web pages into the DIYLC
|
||||
* cloud. Each new file is uploaded as a project and each changed file replaces the files of the
|
||||
* project it was uploaded as, through the same API calls the Upload action makes on the user's
|
||||
* behalf. Meant to run headless from cron:
|
||||
*
|
||||
* <pre>
|
||||
* java -cp diylc.jar org.diylc.swing.plugins.cloud.sync.CloudSyncJob cloud-sync.json [--dry-run]
|
||||
* </pre>
|
||||
*
|
||||
* With {@code --dry-run} files are downloaded and thumbnails rendered into local storage, but
|
||||
* nothing is uploaded and the sync state is left untouched.
|
||||
*
|
||||
* @author Branislav Stojkovic
|
||||
*/
|
||||
public class CloudSyncJob {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(CloudSyncJob.class);
|
||||
|
||||
private static final String DRY_RUN = "--dry-run";
|
||||
private static final String SUCCESS = "Success";
|
||||
private static final String STATE_FILE = "sync-state.json";
|
||||
private static final String CONFIGURATION_NAME = "diylc-cloud-sync";
|
||||
private static final String SORT_BY_AGE = "Age";
|
||||
// column sizes of diylc_project on the server
|
||||
private static final int MAX_NAME_LENGTH = 50;
|
||||
private static final int MAX_DESCRIPTION_LENGTH = 1024;
|
||||
|
||||
private final CloudSyncConfig config;
|
||||
private final File storageDir;
|
||||
private final boolean dryRun;
|
||||
private final IServiceAPI service;
|
||||
private final PageScraper scraper;
|
||||
private final RecordingView view = new RecordingView();
|
||||
private final Presenter presenter;
|
||||
private final ThumbnailGenerator thumbnailGenerator;
|
||||
private final String diylcVersion;
|
||||
private List<String> categories;
|
||||
// projects the current user already has in the cloud, fetched only once a file is found that
|
||||
// the sync state does not know
|
||||
private List<ProjectEntity> uploads;
|
||||
|
||||
public CloudSyncJob(CloudSyncConfig config, File storageDir, boolean dryRun) {
|
||||
this(config, storageDir, dryRun, new ProxyFactory(new PhpFlatProxy())
|
||||
.createProxy(IServiceAPI.class, config.serviceUrlOrDefault()), new PageScraper());
|
||||
}
|
||||
|
||||
CloudSyncJob(CloudSyncConfig config, File storageDir, boolean dryRun, IServiceAPI service,
|
||||
PageScraper scraper) {
|
||||
this.config = config;
|
||||
this.storageDir = storageDir;
|
||||
this.dryRun = dryRun;
|
||||
this.service = service;
|
||||
this.scraper = scraper;
|
||||
this.presenter = new Presenter(view, InMemoryConfigurationManager.getInstance());
|
||||
this.thumbnailGenerator = new ThumbnailGenerator(presenter);
|
||||
this.diylcVersion = presenter.getCurrentVersionNumber().toString();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.setProperty("java.awt.headless", "true");
|
||||
// replaces the application's log4j.properties, which log4j picks up from the classpath
|
||||
LogManager.resetConfiguration();
|
||||
BasicConfigurator.configure();
|
||||
Logger.getRootLogger().setLevel(Level.WARN);
|
||||
LOG.setLevel(Level.INFO);
|
||||
|
||||
List<String> arguments = new ArrayList<>(Arrays.asList(args));
|
||||
boolean dryRun = arguments.remove(DRY_RUN);
|
||||
if (arguments.size() != 1) {
|
||||
System.err.println("Usage: CloudSyncJob <config.json> [" + DRY_RUN + "]");
|
||||
System.exit(2);
|
||||
}
|
||||
|
||||
// components read drawing options from the global configuration, so it needs initializing;
|
||||
// a name of its own keeps the job on default settings and away from the user's config
|
||||
ConfigurationManager.getInstance().initialize(CONFIGURATION_NAME);
|
||||
|
||||
int failures;
|
||||
try {
|
||||
File configFile = new File(arguments.get(0));
|
||||
CloudSyncConfig config = CloudSyncConfig.load(configFile);
|
||||
failures = new CloudSyncJob(config, config.resolveStorageDir(configFile), dryRun).run();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Cloud sync failed", e);
|
||||
failures = 1;
|
||||
}
|
||||
System.exit(failures == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the number of pages and files that could not be synced
|
||||
*/
|
||||
public int run() throws CloudException {
|
||||
categories = service.getCategories();
|
||||
if (categories == null || categories.isEmpty()) {
|
||||
throw new CloudException("Could not fetch categories from the server.");
|
||||
}
|
||||
int failures = 0;
|
||||
for (UserConfig user : config.users()) {
|
||||
failures += syncUser(user);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private int syncUser(UserConfig user) {
|
||||
File userDir = new File(storageDir, Integer.toString(user.userId()));
|
||||
Map<Outcome, Integer> outcomes = new EnumMap<>(Outcome.class);
|
||||
try {
|
||||
validate(user);
|
||||
SyncState state = SyncState.load(new File(userDir, STATE_FILE));
|
||||
uploads = null;
|
||||
Set<URI> seen = new HashSet<>();
|
||||
for (PageConfig page : user.pages()) {
|
||||
List<URI> links;
|
||||
try {
|
||||
links = scraper.findDiyLinks(URI.create(page.url()));
|
||||
} catch (Exception e) {
|
||||
LOG.error("Could not scrape " + page.url(), e);
|
||||
outcomes.merge(Outcome.FAILED, 1, Integer::sum);
|
||||
continue;
|
||||
}
|
||||
for (URI link : links) {
|
||||
if (seen.add(link)) {
|
||||
Outcome outcome;
|
||||
try {
|
||||
outcome = syncFile(user, page, link, userDir, state);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Could not sync " + link, e);
|
||||
outcome = Outcome.FAILED;
|
||||
}
|
||||
outcomes.merge(outcome, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Could not sync user " + user.userId(), e);
|
||||
outcomes.merge(Outcome.FAILED, 1, Integer::sum);
|
||||
}
|
||||
LOG.info("User " + user.userId() + (dryRun ? " (dry run): " : ": ") + outcomes);
|
||||
return outcomes.getOrDefault(Outcome.FAILED, 0);
|
||||
}
|
||||
|
||||
private void validate(UserConfig user) throws CloudException {
|
||||
if (!dryRun && (isBlank(user.username()) || isBlank(user.token())
|
||||
|| isBlank(user.machineId()))) {
|
||||
throw new CloudException("username, token and machineId are required");
|
||||
}
|
||||
if (user.pages() == null || user.pages().isEmpty()) {
|
||||
throw new CloudException("No pages configured");
|
||||
}
|
||||
for (PageConfig page : user.pages()) {
|
||||
resolveCategory(user.categoryFor(page));
|
||||
}
|
||||
}
|
||||
|
||||
private Outcome syncFile(UserConfig user, PageConfig page, URI uri, File userDir,
|
||||
SyncState state) throws Exception {
|
||||
String url = uri.toString();
|
||||
byte[] content = scraper.download(uri);
|
||||
String sha256 = sha256(content);
|
||||
SyncedFile synced = state.get(url);
|
||||
if (synced != null && synced.sha256().equals(sha256)) {
|
||||
return Outcome.UNCHANGED;
|
||||
}
|
||||
|
||||
File projectFile = localFile(userDir, uri);
|
||||
projectFile.getParentFile().mkdirs();
|
||||
Files.write(projectFile.toPath(), content);
|
||||
File thumbnailFile = new File(projectFile.getPath() + ".png");
|
||||
String projectDescription = renderThumbnail(projectFile, thumbnailFile);
|
||||
String name = projectName(uri);
|
||||
|
||||
// a project already in the cloud under the same name, uploaded by hand or by a run whose
|
||||
// state was lost, is taken over rather than duplicated
|
||||
Integer projectId = synced != null ? Integer.valueOf(synced.projectId())
|
||||
: findUnclaimed(getUploads(user), name, state, Comparator.naturalOrder()).orElse(null);
|
||||
Outcome outcome = projectId == null ? Outcome.UPLOADED : Outcome.UPDATED;
|
||||
if (dryRun) {
|
||||
LOG.info((projectId == null ? "Would upload " : "Would update project " + projectId
|
||||
+ " from ") + url);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
if (projectId == null) {
|
||||
checkResponse(service.uploadProject(user.username(), user.token(), user.machineId(), name,
|
||||
resolveCategory(user.categoryFor(page)), description(projectDescription, uri),
|
||||
diylcVersion, user.keywordsFor(page), thumbnailFile, projectFile, null));
|
||||
// the API does not return the ID of the new project, so the newest unclaimed one with the
|
||||
// same name is taken to be it
|
||||
uploads = fetchUploads(user);
|
||||
projectId = findUnclaimed(uploads, name, state, Comparator.reverseOrder())
|
||||
.orElseThrow(() -> new CloudException(
|
||||
"Uploaded " + url + " but could not find it among the user's projects"));
|
||||
LOG.info("Uploaded " + url + " as project " + projectId);
|
||||
} else {
|
||||
checkResponse(service.replaceProjectFiles(user.username(), user.token(),
|
||||
user.machineId(), diylcVersion, thumbnailFile, projectFile, projectId));
|
||||
LOG.info("Updated project " + projectId + " from " + url);
|
||||
}
|
||||
state.put(new SyncedFile(url, projectId, sha256, Instant.now().toString()));
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the project into the off-screen presenter and writes its thumbnail.
|
||||
*
|
||||
* @return the project description stored in the file
|
||||
*/
|
||||
private String renderThumbnail(File projectFile, File thumbnailFile)
|
||||
throws IOException, CloudException {
|
||||
view.takeError();
|
||||
presenter.loadProjectFromFile(projectFile.getAbsolutePath());
|
||||
String error = view.takeError();
|
||||
if (error != null) {
|
||||
throw new CloudException(error);
|
||||
}
|
||||
if (!ImageIO.write(thumbnailGenerator.getThumbnail(), "png", thumbnailFile)) {
|
||||
throw new IOException("Could not write " + thumbnailFile);
|
||||
}
|
||||
return presenter.getCurrentProject().getDescription();
|
||||
}
|
||||
|
||||
private List<ProjectEntity> getUploads(UserConfig user) throws CloudException {
|
||||
if (uploads == null) {
|
||||
uploads = isBlank(user.username()) ? List.of() : fetchUploads(user);
|
||||
}
|
||||
return uploads;
|
||||
}
|
||||
|
||||
private List<ProjectEntity> fetchUploads(UserConfig user) throws CloudException {
|
||||
Object res =
|
||||
service.search("", "", 1, Integer.MAX_VALUE, SORT_BY_AGE, user.username(), null);
|
||||
if (res instanceof List<?> list) {
|
||||
return list.stream().map(ProjectEntity.class::cast).toList();
|
||||
}
|
||||
// a single result is not wrapped in a list, and no results come back as an empty string
|
||||
if (res instanceof ProjectEntity project) {
|
||||
return List.of(project);
|
||||
}
|
||||
if (res == null || res instanceof String s && s.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
throw new CloudException("Unexpected response while fetching user projects: " + res);
|
||||
}
|
||||
|
||||
private static Optional<Integer> findUnclaimed(List<ProjectEntity> uploads, String name,
|
||||
SyncState state, Comparator<Integer> preference) {
|
||||
return uploads.stream()
|
||||
.filter(p -> name.equals(p.getName()) && !state.isClaimed(p.getId()))
|
||||
.map(ProjectEntity::getId)
|
||||
.min(preference);
|
||||
}
|
||||
|
||||
private String resolveCategory(String configured) throws CloudException {
|
||||
if (configured != null) {
|
||||
String wanted = stripSubcategoryPrefix(configured);
|
||||
for (String category : categories) {
|
||||
if (stripSubcategoryPrefix(category).equalsIgnoreCase(wanted)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new CloudException("Unknown category '" + configured + "', expected one of "
|
||||
+ categories);
|
||||
}
|
||||
|
||||
// subcategories are listed as "- Name" and the server matches the category by that full string
|
||||
private static String stripSubcategoryPrefix(String category) {
|
||||
String trimmed = category.trim();
|
||||
return trimmed.startsWith("- ") ? trimmed.substring(2).trim() : trimmed;
|
||||
}
|
||||
|
||||
private static void checkResponse(String res) throws CloudException {
|
||||
if (!SUCCESS.equals(res)) {
|
||||
throw new CloudException(res);
|
||||
}
|
||||
}
|
||||
|
||||
static String projectName(URI uri) {
|
||||
String path = uri.getPath();
|
||||
String name = path.substring(path.lastIndexOf('/') + 1)
|
||||
.replaceFirst("(?i)\\.diy$", "")
|
||||
.replace('_', ' ')
|
||||
.replaceAll("\\s+", " ")
|
||||
.trim();
|
||||
return name.length() > MAX_NAME_LENGTH ? name.substring(0, MAX_NAME_LENGTH).trim() : name;
|
||||
}
|
||||
|
||||
static String description(String projectDescription, URI source) {
|
||||
String sourceLine = "Source: " + source;
|
||||
if (isBlank(projectDescription)) {
|
||||
return sourceLine;
|
||||
}
|
||||
String text = projectDescription.trim();
|
||||
int room = MAX_DESCRIPTION_LENGTH - sourceLine.length() - 2;
|
||||
if (text.length() > room) {
|
||||
text = text.substring(0, room).trim();
|
||||
}
|
||||
return text + "\n\n" + sourceLine;
|
||||
}
|
||||
|
||||
static File localFile(File userDir, URI uri) throws IOException {
|
||||
File root = userDir.getCanonicalFile();
|
||||
File file = new File(new File(root, uri.getHost()), uri.getPath()).getCanonicalFile();
|
||||
if (!file.toPath().startsWith(root.toPath())) {
|
||||
throw new IOException("Refusing to store " + uri + " outside " + root);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private static String sha256(byte[] content) throws NoSuchAlgorithmException {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
private enum Outcome {
|
||||
UNCHANGED, UPLOADED, UPDATED, FAILED
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Presenter#loadProjectFromFile(String)} reports a failed load only through the view, so
|
||||
* the error is captured here to keep a stale project from being uploaded in its place.
|
||||
*/
|
||||
private static class RecordingView extends DummyView {
|
||||
|
||||
private String error;
|
||||
|
||||
@Override
|
||||
public void showMessage(String message, String title, int messageType) {
|
||||
if (messageType == IView.ERROR_MESSAGE) {
|
||||
error = message;
|
||||
}
|
||||
}
|
||||
|
||||
String takeError() {
|
||||
String result = error;
|
||||
error = null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.swing.plugins.cloud.sync;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Finds links to .diy files on a web page and downloads them.
|
||||
*/
|
||||
public class PageScraper {
|
||||
|
||||
private static final Pattern DIY_LINK =
|
||||
Pattern.compile("href\\s*=\\s*[\"']([^\"']+\\.diy)[\"']", Pattern.CASE_INSENSITIVE);
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(60);
|
||||
private static final String USER_AGENT = "DIYLC-CloudSync";
|
||||
|
||||
private final HttpClient client = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(TIMEOUT)
|
||||
.build();
|
||||
|
||||
public List<URI> findDiyLinks(URI page) throws IOException, InterruptedException {
|
||||
return extractDiyLinks(page, new String(download(page), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public byte[] download(URI uri) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder(uri)
|
||||
.timeout(TIMEOUT)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode() + " while downloading " + uri);
|
||||
}
|
||||
return response.body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns absolute URIs of every distinct .diy link in the page, in page order. Links are taken
|
||||
* as written in the markup, so entity-escaped ampersands are decoded and the raw spaces some
|
||||
* pages use in file names are percent-encoded.
|
||||
*/
|
||||
static List<URI> extractDiyLinks(URI base, String html) {
|
||||
Set<URI> links = new LinkedHashSet<>();
|
||||
Matcher matcher = DIY_LINK.matcher(html);
|
||||
while (matcher.find()) {
|
||||
String href = matcher.group(1).trim().replace("&", "&").replace(" ", "%20");
|
||||
try {
|
||||
links.add(base.resolve(href).normalize());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// not a well-formed URI even after encoding spaces; the link is unusable
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(links);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.swing.plugins.cloud.sync;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
/**
|
||||
* Remembers, per source URL, which cloud project a file was uploaded as and the hash of the content
|
||||
* that was uploaded, so that later runs only upload what is new or changed.
|
||||
*/
|
||||
public class SyncState {
|
||||
|
||||
private static final ObjectMapper MAPPER =
|
||||
new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
private final File file;
|
||||
private final Map<String, SyncedFile> filesByUrl = new LinkedHashMap<>();
|
||||
|
||||
private SyncState(File file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public static SyncState load(File file) throws IOException {
|
||||
SyncState state = new SyncState(file);
|
||||
if (file.exists()) {
|
||||
Snapshot snapshot = MAPPER.readValue(file, Snapshot.class);
|
||||
for (SyncedFile synced : snapshot.files()) {
|
||||
state.filesByUrl.put(synced.url(), synced);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
public SyncedFile get(String url) {
|
||||
return filesByUrl.get(url);
|
||||
}
|
||||
|
||||
public boolean isClaimed(int projectId) {
|
||||
return filesByUrl.values().stream().anyMatch(f -> f.projectId() == projectId);
|
||||
}
|
||||
|
||||
/** Records the file and writes the state out immediately, so an interrupted run loses nothing. */
|
||||
public void put(SyncedFile synced) throws IOException {
|
||||
filesByUrl.put(synced.url(), synced);
|
||||
file.getParentFile().mkdirs();
|
||||
File temp = new File(file.getPath() + ".tmp");
|
||||
MAPPER.writeValue(temp, new Snapshot(new ArrayList<>(filesByUrl.values())));
|
||||
Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
|
||||
public record SyncedFile(String url, int projectId, String sha256, String syncedOn) {}
|
||||
|
||||
private record Snapshot(List<SyncedFile> files) {}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.swing.plugins.cloud.sync;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.diylc.appframework.miscutils.ConfigurationManager;
|
||||
import org.diylc.appframework.miscutils.InMemoryConfigurationManager;
|
||||
import org.diylc.common.DummyView;
|
||||
import org.diylc.plugins.cloud.model.IServiceAPI;
|
||||
import org.diylc.plugins.cloud.model.ProjectEntity;
|
||||
import org.diylc.presenter.Presenter;
|
||||
import org.diylc.swing.plugins.cloud.sync.CloudSyncConfig.PageConfig;
|
||||
import org.diylc.swing.plugins.cloud.sync.CloudSyncConfig.UserConfig;
|
||||
|
||||
public class CloudSyncJobTests {
|
||||
|
||||
private static final URI PAGE = URI.create("https://example.com/files/Page/Page.htm");
|
||||
private static final URI FILE = URI.create("https://example.com/files/Page/My_Amp.diy");
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder folder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void uploadsNewFilesOnceAndReplacesChangedOnes() throws Exception {
|
||||
FakeCloud cloud = new FakeCloud();
|
||||
FakeScraper scraper = new FakeScraper(emptyProject());
|
||||
|
||||
assertEquals(0, newJob(cloud, scraper).run());
|
||||
assertEquals(List.of("search", "uploadProject My Amp", "search"), cloud.writesAndLookups);
|
||||
assertEquals("Source: " + FILE, cloud.lastDescription);
|
||||
|
||||
cloud.writesAndLookups.clear();
|
||||
assertEquals(0, newJob(cloud, scraper).run());
|
||||
assertEquals(List.of(), cloud.writesAndLookups);
|
||||
|
||||
scraper.content = new String(scraper.content, StandardCharsets.UTF_8)
|
||||
.replace("New Project", "Changed Project").getBytes(StandardCharsets.UTF_8);
|
||||
assertEquals(0, newJob(cloud, scraper).run());
|
||||
assertEquals(List.of("replaceProjectFiles 1"), cloud.writesAndLookups);
|
||||
assertEquals(1, cloud.projects.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void takesOverProjectAlreadyUploadedUnderSameName() throws Exception {
|
||||
FakeCloud cloud = new FakeCloud();
|
||||
cloud.add("My Amp");
|
||||
cloud.add("Something Else");
|
||||
|
||||
assertEquals(0, newJob(cloud, new FakeScraper(emptyProject())).run());
|
||||
assertEquals(List.of("search", "replaceProjectFiles 1"), cloud.writesAndLookups);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unreadableFileIsReportedAndNotUploaded() throws Exception {
|
||||
FakeCloud cloud = new FakeCloud();
|
||||
FakeScraper scraper = new FakeScraper("not a project".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals(1, newJob(cloud, scraper).run());
|
||||
assertEquals(List.of(), cloud.writesAndLookups);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void extractsDistinctDiyLinksInPageOrder() {
|
||||
String html = "<a href=\"https://example.com/files/Page/A_B.diy\">A_B.diy</a>"
|
||||
+ "<a href='Readme.htm'>readme</a>"
|
||||
+ "<a HREF=\"Sub/Bandmaster 5E7.DIY\">x</a>"
|
||||
+ "<a href=\"https://example.com/files/Page/A_B.diy\">again</a>";
|
||||
List<URI> links = PageScraper.extractDiyLinks(PAGE, html);
|
||||
assertEquals(List.of(URI.create("https://example.com/files/Page/A_B.diy"),
|
||||
URI.create("https://example.com/files/Page/Sub/Bandmaster%205E7.DIY")), links);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void derivesProjectNameFromFileName() {
|
||||
assertEquals("Fender Bandmaster 5E7", CloudSyncJob.projectName(
|
||||
URI.create("https://example.com/Fender_Bandmaster%205E7.diy")));
|
||||
String longName = CloudSyncJob.projectName(URI.create("https://example.com/"
|
||||
+ "AA1164_Princeton_Reverb_Layout_Reverb_and_Tremolo_Deleted_Hoffman_Board.diy"));
|
||||
assertEquals("AA1164 Princeton Reverb Layout Reverb and Tremolo", longName);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void descriptionAlwaysCitesSource() {
|
||||
URI source = URI.create("https://example.com/a.diy");
|
||||
assertEquals("Source: https://example.com/a.diy", CloudSyncJob.description(null, source));
|
||||
String description = CloudSyncJob.description("x".repeat(2000), source);
|
||||
assertEquals(1024, description.length());
|
||||
assertTrue(description.endsWith("\n\nSource: https://example.com/a.diy"));
|
||||
}
|
||||
|
||||
@Test(expected = IOException.class)
|
||||
public void refusesToStoreOutsideUserDirectory() throws IOException {
|
||||
CloudSyncJob.localFile(new File("target/sync-test"),
|
||||
URI.create("https://example.com/a/../../../../escape.diy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readsPagesAsBareUrlsOrObjects() throws IOException {
|
||||
String json = "{\"users\":[{\"userId\":1,\"category\":\"Other\",\"pages\":["
|
||||
+ "\"https://example.com/a.htm\","
|
||||
+ "{\"url\":\"https://example.com/b.htm\",\"category\":\"Guitar Amplifiers\"}]}]}";
|
||||
CloudSyncConfig config = new ObjectMapper().readValue(json, CloudSyncConfig.class);
|
||||
CloudSyncConfig.UserConfig user = config.users().get(0);
|
||||
assertEquals("Other", user.categoryFor(user.pages().get(0)));
|
||||
assertEquals("Guitar Amplifiers", user.categoryFor(user.pages().get(1)));
|
||||
assertEquals("", user.keywordsFor(user.pages().get(1)));
|
||||
assertEquals(CloudSyncConfig.DEFAULT_SERVICE_URL, config.serviceUrlOrDefault());
|
||||
}
|
||||
|
||||
private CloudSyncJob newJob(FakeCloud cloud, FakeScraper scraper) throws IOException {
|
||||
UserConfig user = new UserConfig(7, "someone", "cron-job", "token", "Guitar Amplifiers", null,
|
||||
List.of(PageConfig.of(PAGE.toString())));
|
||||
CloudSyncConfig config = new CloudSyncConfig(null, null, List.of(user));
|
||||
return new CloudSyncJob(config, new File(folder.getRoot(), "storage"), false, cloud.api(),
|
||||
scraper);
|
||||
}
|
||||
|
||||
private byte[] emptyProject() throws IOException {
|
||||
ConfigurationManager.getInstance().initialize("diylc-test");
|
||||
File file = folder.newFile("empty.diy");
|
||||
new Presenter(new DummyView(), InMemoryConfigurationManager.getInstance())
|
||||
.saveProjectToFile(file.getAbsolutePath(), false);
|
||||
return Files.readAllBytes(file.toPath());
|
||||
}
|
||||
|
||||
private static class FakeScraper extends PageScraper {
|
||||
|
||||
private byte[] content;
|
||||
|
||||
FakeScraper(byte[] content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<URI> findDiyLinks(URI page) {
|
||||
return List.of(FILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] download(URI uri) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
private static class FakeCloud {
|
||||
|
||||
private final List<ProjectEntity> projects = new ArrayList<>();
|
||||
private final List<String> writesAndLookups = new ArrayList<>();
|
||||
private String lastDescription;
|
||||
|
||||
void add(String name) {
|
||||
ProjectEntity project = new ProjectEntity();
|
||||
project.setId(projects.size() + 1);
|
||||
project.setName(name);
|
||||
projects.add(project);
|
||||
}
|
||||
|
||||
IServiceAPI api() {
|
||||
return (IServiceAPI) Proxy.newProxyInstance(getClass().getClassLoader(),
|
||||
new Class<?>[] {IServiceAPI.class}, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getCategories":
|
||||
return new ArrayList<>(List.of("Other", "- Guitar Amplifiers"));
|
||||
case "search":
|
||||
writesAndLookups.add("search");
|
||||
return new ArrayList<>(projects);
|
||||
case "uploadProject":
|
||||
assertEquals("- Guitar Amplifiers", args[4]);
|
||||
lastDescription = (String) args[5];
|
||||
add((String) args[3]);
|
||||
writesAndLookups.add("uploadProject " + args[3]);
|
||||
return "Success";
|
||||
case "replaceProjectFiles":
|
||||
writesAndLookups.add("replaceProjectFiles " + args[6]);
|
||||
return "Success";
|
||||
default:
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user