Cloud project scraper added

This commit is contained in:
Bane Stojkovic
2026-10-13 16:04:51 +02:00
parent 84726e6d16
commit 1ec8805c91
10 changed files with 973 additions and 0 deletions
+4
View File
@@ -154,3 +154,7 @@ buildNumber.properties
# Deployment folder
deploy/
# Cloud sync job: credentials and downloaded files
cloud-sync/cloud-sync.json
cloud-sync/storage/
+14
View File
@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="CloudSyncJob (dry run)" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="org.diylc.swing.plugins.cloud.sync.CloudSyncJob" />
<module name="diylc-swing" />
<option name="PROGRAM_PARAMETERS" value="$PROJECT_DIR$/cloud-sync/cloud-sync.json --dry-run" />
<option name="VM_PARAMETERS" value="-Djava.awt.headless=true -Dlog4j.defaultInitOverride=true --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.desktop/java.awt=ALL-UNNAMED --add-opens java.desktop/java.awt.font=ALL-UNNAMED --add-opens java.desktop/java.awt.geom=ALL-UNNAMED" />
<RunnerSettings RunnerId="Profile ">
<option name="yk-options-v2" value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;&#10;&lt;!DOCTYPE properties SYSTEM &quot;http://java.sun.com/dtd/properties.dtd&quot;&gt;&#10;&lt;properties&gt;&#10;&lt;entry key=&quot;advanced-options&quot;&gt;on_exit=snapshot&lt;/entry&gt;&#10;&lt;entry key=&quot;jfr&quot;&gt;Off&lt;/entry&gt;&#10;&lt;entry key=&quot;cpu&quot;&gt;Sampling&lt;/entry&gt;&#10;&lt;entry key=&quot;monitor&quot;&gt;Off&lt;/entry&gt;&#10;&lt;entry key=&quot;telemetry&quot;&gt;On&lt;/entry&gt;&#10;&lt;entry key=&quot;thread&quot;&gt;States and stacks&lt;/entry&gt;&#10;&lt;entry key=&quot;time&quot;&gt;Wall time&lt;/entry&gt;&#10;&lt;entry key=&quot;alloc&quot;&gt;Off&lt;/entry&gt;&#10;&lt;entry key=&quot;launch-ui&quot;&gt;true&lt;/entry&gt;&#10;&lt;entry key=&quot;exceptions&quot;&gt;On&lt;/entry&gt;&#10;&lt;/properties&gt;&#10;" />
</RunnerSettings>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>
+37
View File
@@ -0,0 +1,37 @@
# Cloud sync job
`org.diylc.swing.plugins.cloud.sync.CloudSyncJob` mirrors `.diy` files that users publish on their
own web pages into the DIYLC cloud, on behalf of users who agreed to it. Each run scrapes the
configured pages for `.diy` links and downloads every file. A file that is new is uploaded as a
project with a rendered thumbnail. A file whose content changed replaces the files of the project
it was uploaded as. Unchanged files are skipped, and files that disappear from a page are left in
the cloud.
## Configuration
Copy `cloud-sync.example.json` to `cloud-sync.json` (git-ignored, because it holds login tokens).
For each user:
- `username`, `token`, `machineId`: the cloud API authenticates with the username (or email), not
the numeric ID, plus a login token issued for that machine ID.
- `userId`: names the user's folder under `storageDir`.
- `category`, `keywords`: defaults for the user's pages. Category is the name shown in the upload
dialog; the leading `- ` of subcategories is optional.
- `pages`: page URLs. Use an object with `url`, `category` and `keywords` to override the defaults
for one page.
The project name comes from the file name, and the description from the file's own description
followed by a `Source:` link.
## Running
```bash
mvn -q -pl diylc-swing -am package -DskipTests # builds diylc-swing/target/diylc.jar
cloud-sync/cloud-sync.sh --dry-run # download and render only
cloud-sync/cloud-sync.sh # sync for real
```
`storage/<userId>/` mirrors the downloaded files with their thumbnails (`*.diy.png`), and keeps
`sync-state.json`, which maps each source URL to its cloud project ID and the hash that was last
uploaded. If the state is lost, the next run takes over the user's existing projects by name
instead of uploading duplicates. The exit status is non-zero if any page or file failed.
+21
View File
@@ -0,0 +1,21 @@
{
"storageDir": "storage",
"users": [
{
"userId": 123,
"username": "cloud username or email of the user",
"machineId": "cron-job",
"token": "login token issued to this username and machineId",
"category": "Guitar Amplifiers",
"keywords": "",
"pages": [
"https://example.com/layouts.htm",
{
"url": "https://example.com/templates.htm",
"category": "Other",
"keywords": "template"
}
]
}
]
}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Syncs the pages listed in cloud-sync.json to the DIYLC cloud. Pass --dry-run to download and
# render thumbnails without uploading. Uses the jar built by "mvn package" unless DIYLC_JAR is set.
DIR="$(cd "$(dirname "$0")" && pwd)"
JAR="${DIYLC_JAR:-$DIR/../diylc-swing/target/diylc.jar}"
exec java -Xmx2g -Djava.awt.headless=true -Dlog4j.defaultInitOverride=true \
--add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.desktop/java.awt=ALL-UNNAMED --add-opens java.desktop/java.awt.font=ALL-UNNAMED --add-opens java.desktop/java.awt.geom=ALL-UNNAMED \
-cp "$JAR" org.diylc.swing.plugins.cloud.sync.CloudSyncJob "$DIR/cloud-sync.json" "$@"
@@ -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("&amp;", "&").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) {}
}
@@ -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());
}
});
}
}
}