mirror of
https://github.com/bancika/diy-layout-creator
synced 2026-11-07 12:24:58 +10:00
Cloud project scraper added
This commit is contained in:
@@ -154,3 +154,7 @@ buildNumber.properties
|
||||
|
||||
# Deployment folder
|
||||
deploy/
|
||||
|
||||
# Cloud sync job: credentials and downloaded files
|
||||
cloud-sync/cloud-sync.json
|
||||
cloud-sync/storage/
|
||||
|
||||
@@ -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="<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd"> <properties> <entry key="advanced-options">on_exit=snapshot</entry> <entry key="jfr">Off</entry> <entry key="cpu">Sampling</entry> <entry key="monitor">Off</entry> <entry key="telemetry">On</entry> <entry key="thread">States and stacks</entry> <entry key="time">Wall time</entry> <entry key="alloc">Off</entry> <entry key="launch-ui">true</entry> <entry key="exceptions">On</entry> </properties> " />
|
||||
</RunnerSettings>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+8
@@ -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" "$@"
|
||||
+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