@proca/widget
Version:
Proca is an open-source campaign toolkit designed to empower activists and organisations in their digital advocacy efforts. It provides a flexible and customisable platform for creating and managing online petitions, email campaigns, and other forms of di
559 lines (521 loc) • 16.6 kB
JavaScript
const fs = require("fs");
require("dotenv").config();
const i18n = require("./lang").i18next;
const { commit, add, onGit } = require("./git");
const { publishTarget } = require("./publishTargets");
const color = require("cli-color");
const { mainLanguage } = require("./lang");
const { mkdirp, read, file, api, fileExists } = require("./config");
const help = exitValue => {
console.log(
color.yellow(
[
"options",
"--help (this command)",
"--dry-run (show the tagets but don't update the server)",
// "not done --twitter (set up as a separate proca-twitter)",
"--git (git update [add]+commit into /config/target/) || --no-git",
"--quiet (less warning displayed)",
"--pull (from the server)",
"--digest (process the source and generate a file for digest, like add salutation and language)",
"--push (update the server)",
"--display (show/hide targets)",
"--publish (update the public list into /config/target/public and make it live)",
"{campaign name}",
].join("\n")
),
color.blackBright(
[
"",
"(if --push or --digest)",
"--allow-duplicate (false by default) can two targets have the same email?",
"--salutation(add a salutation column based on the gender and language)",
"--outdated[=delete,disable,keep] (by default, replace all the contacts and delete those that aren't on the file, option to disable or keep)",
"--source[=true] (filter the server list to only keep the targets in the source - if the server has more targets than the source/--disable or keep)",
"--file=file (by default, config/target/source/{campaign name}.json",
].join("\n")
),
color.blackBright(
[
"",
"(if --publish)",
"--email (for campaigns sending client side)",
"--display (filters based on the display field)",
"--source (filter the server list based on source - if the server has more targets than the source)",
"--meps , special formatting, done if 'epid' is a field",
"--[no-]external_id , publishes the externalid",
"--fields=fieldA,fieldB add extra fields present in source, eg for custom filtering",
].join("\n")
)
);
process.exit(+exitValue);
};
const argv = require("minimist")(process.argv.slice(2), {
default: {
git: true,
salutation: true,
external_id: true,
source: true,
outdated: "delete",
quiet: false,
"allow-duplicate": false,
},
string: ["file", "fields", "outdated"],
boolean: [
"help",
"dry-run",
"allow-duplicate",
"quiet",
"keep",
"git",
"pull",
"digest",
"push",
"publish",
// "twitter",
"source",
"salutation",
"meps",
"external_id",
"email",
"display",
],
unknown: d => {
const allowed = []; //merge with boolean and string?
if (d[0] !== "-") return true;
if (allowed.includes(d.split("=")[0].slice(2))) return true;
console.log(color.red("unknown param", d));
help(1);
},
});
if (argv._.length !== 1) {
if (argv._.length === 0) {
console.log(color.red("missing campaign name"), argv._);
} else {
console.log(color.red("only one campaign param allowed"), argv._);
}
help(true);
}
const parseEmail = text => {
const emails =
text && text.match(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/gi);
if (!emails) {
!argv.quiet && console.log("failed to parse as an email", text);
return [];
}
return emails.map(email => ({ email: email })); // proca api requires an array of {email:bla@example.org}
};
const getCampaignTargets = async name => {
const query = `
query GetCampaignTargets($name: String!) {
campaign(name:$name) {
... on PrivateCampaign {
targets {
id name area fields locale externalId
... on PrivateTarget {
emails { email, emailStatus }
}
}
}
}
}
`;
const data = await api(query, { name }, "GetCampaignTargets");
if (!data.campaign) throw new Error("can't find campaign " + name);
if (!data.campaign.targets || data.campaign.targets.length === 0)
throw new Error("No targets.");
data.campaign.targets = data.campaign.targets.map(t => {
if (t.fields) t.fields = JSON.parse(t.fields);
return t;
});
return data.campaign.targets;
};
const countEmailStatus = targets => {
return targets.reduce((acc, { emails }) => {
if (emails.length > 1) {
console.warn("more than one email per target", emails);
}
emails.forEach(
({ emailStatus }) =>
(acc[emailStatus] = acc[emailStatus] ? acc[emailStatus] + 1 : 1)
);
return acc;
}, {});
};
const pullTarget = async name => {
let targets = await getCampaignTargets(name);
const status = countEmailStatus(targets);
if (targets.length === 0) {
return console.error("not storing empty targets");
}
if (argv["dry-run"]) return console.log(JSON.stringify(targets, null, 2));
if (argv.source) {
const sources = read("target/source/" + name); // the list of targets from the source
const c = targets.filter(
t => -1 !== sources.findIndex(d => d.externalId === t.externalId)
);
if (targets.length !== c.length) {
console.log("total server vs source", targets.length, c.length);
targets = c;
}
}
if (Object.keys(status).length > 1) {
console.log("status", status);
}
await saveTargets(argv.file || name, targets);
console.log("save target");
return targets;
};
const readTarget = targetName => {
const fileName = file("target/" + targetName);
const target = JSON.parse(fs.readFileSync(fileName));
return target;
};
const saveDigest = async (targetName, targets) => {
mkdirp("target/digest");
const fileName = file("target/digest/" + targetName);
const exists = fileExists("target/digest/" + targetName);
fs.writeFileSync(fileName, JSON.stringify(targets, null, 2));
console.log(
color.green.bold("saving " + targets.length + " targets into", fileName)
);
let r = null;
const msg = "saving " + targets.length + " targets";
if (argv.git) {
if (!exists) {
r = await add(fileName);
console.log("adding", fileName);
}
r = argv.git && (await commit(fileName, msg, true));
console.log(r.summary);
}
return fileName;
};
const saveTargets = async (targetName, targets) => {
const fileName = file("target/server/" + targetName);
const exists = fileExists("target/server/" + targetName);
fs.writeFileSync(fileName, JSON.stringify(targets, null, 2));
console.log(
color.green.bold("pulled " + targets.length + " targets into", fileName)
);
let r = null;
const msg = "saving " + targets.length + " targets";
if (argv.git) {
if (!exists) {
r = await add(fileName);
console.log("adding", fileName);
}
r = argv.git && (await commit(fileName, msg, true));
console.log(r.summary);
}
return fileName;
};
const getTwitter = async target => {
const targetName =
(target.config.twitter && target.config.twitter.screen_name) || target.name;
try {
const res = await fetch(
"https://twitter.proca.app/?screen_name=" + targetName
);
if (res.status >= 400) {
throw new Error("Bad response from twitter.proca.app");
}
const twitter = await res.json();
twitter.picture = twitter.profile_image_url_https;
delete twitter.profile_image_url_https;
if (twitter) target.config.twitter = twitter;
if (!target.config.description)
target.config.description = twitter.description;
if (!target.config.location) target.config.location = twitter.location;
if (!target.config.url) target.config.url = twitter.url;
} catch (err) {
console.error(err);
}
};
const summary = campaign => {
const source = read("target/source/" + campaign);
const server = read("target/server/" + campaign);
const publict = read("target/public/" + campaign);
if (argv.file) {
const source = read("target/source/" + argv.file);
console.log(argv.file, " :", source.length);
} else {
console.log("source :", source.length);
}
console.log("server :", server.length);
console.log("public :", publict.length);
};
const formatTarget = async (campaignName, file) => {
const campaign = read("campaign/" + campaignName);
const salutations = {};
if (argv.salutation) {
Object.keys(campaign.config.locales).forEach(lang => {
const common = campaign.config.locales[lang]["common:"];
const salutation = common?.salutation;
if (salutation) salutations[lang] = salutation;
if (!salutation && campaign.config.locales.en["common:"])
salutations[lang] = campaign.config.locales.en["common:"].salutation; //WORKAROUND to default to en
});
}
let targets = read("target/source/" + file);
if (targets === null) {
console.error(color.red("No targets found"));
process.exit(1);
}
const formatTargets = async () => {
const results = [];
const added = new Set();
for (const t of targets) {
if (!t.name) continue; //skip empty records
delete t.id;
if (t.field.lang) {
t.locale = t.field.lang.toLowerCase();
delete t.field.lang;
} else {
const l = mainLanguage(t.area);
if (l) t.locale = l;
}
if (!t.emails) {
t.emails = parseEmail(t.email);
delete t.email;
}
if (t.field.avatar === null) {
!argv.quiet && console.log("null avatar for ", t.name);
delete t.field.avatar;
}
if (t.field.gender === null) {
!argv.quiet && console.log("null gender for ", t.name);
delete t.field.gender;
}
if (!t.field.last_name) {
!argv.quiet &&
console.log("missing lastname for ", t.name, "fallback to name");
t.field.last_name = t.field.name;
}
if (!(t.field.salutation || t.salutation) && argv.salutation) {
let gender = null;
if (t.field.gender) {
if (t.field.gender === "M") gender = "male";
if (t.field.gender === "F") gender = "female";
}
if (salutations[t.locale]) {
t.field.salutation = i18n.t(salutations[t.locale][gender], {
name: t.name,
last_name: t.field.last_name,
first_name: t.field.first_name,
});
} else {
let language = t.locale ? t.locale.replace("_", "-") : "en";
await i18n.loadLanguages(t.locale || "en", err => {
if (!err) return;
console.warn(color.red("missing language", language));
});
await i18n.changeLanguage(language || "en");
t.field.salutation = i18n.t("email.salutation", {
context: gender,
target: {
name: t.name,
last_name: t.field.last_name,
first_name: t.field.first_name,
},
});
// console.log("change language", t.locale,language, t.field.salutation);
}
}
t.fields = JSON.stringify(t.field);
delete t.field;
if (t.emails.length === 0) {
!argv.quiet && console.log("skipping record without email", t.name);
continue;
}
let dupe = false;
!argv["allow-duplicate"] &&
t.emails.forEach(d => {
if (added.has(d.email)) {
!argv.quiet && console.log("target already set", t.name, d.email);
dupe = true;
return;
}
added.add(d.email);
});
if (dupe) continue;
results.push(t);
}
return results;
};
const formattedTargets = await formatTargets();
if (campaign === null) {
console.log("fetch campaign so I can get its name");
return [];
}
if (!formattedTargets || formattedTargets.length === 0) {
console.error("No targets found");
console.log(targets);
process.exit(1);
}
if (argv["verbose"]) {
console.log(JSON.stringify(formattedTargets, null, 2));
}
if (argv["dry-run"]) {
process.exit(0);
}
return formattedTargets;
};
const digestTarget = async (campaignName, file) => {
const targets = await formatTarget(campaignName, file);
console.log("targets", targets.length, file);
const formattedTargets = targets.map(d => {
d.email = d.emails[0].email;
const fields = JSON.parse(d.fields);
delete d.emails;
delete d.fields;
if (!d.locale && d.lang && d.language) {
d.locale = d.lang || d.language;
}
return { ...fields, ...d };
});
saveDigest(argv.file || campaignName, formattedTargets);
};
const pushTarget = async (campaignName, file) => {
const campaign = read("campaign/" + campaignName);
const formattedTargets = await formatTarget(campaignName, file);
console.log("targets", formattedTargets.length);
const query = `
mutation UpsertTargets($id: Int!, $targets: [TargetInput!]!,$outdated:OutdatedTargets!) {
upsertTargets(campaignId: $id, outdatedTargets: $outdated, targets: $targets) {id}
}
`;
const ids = await api(
query,
{
id: campaign.id,
targets: formattedTargets,
outdated: argv.outdated.toUpperCase(),
},
"UpsertTargets"
);
if (ids.errors) {
ids.errors.forEach(d => {
if (d.message === "has messages") {
console.error(
color.red(
"can't remove contact id " +
d.path[2] +
" because is has supporters' messages waiting to be sent"
)
);
console.log(
color.blue(
"you can target --push --keep AND target --publish --source"
)
);
} else {
const line = d.path[2];
console.log(d.path);
console.log(
"error record",
line,
formattedTargets[line]?.name,
formattedTargets[line]?.emails
? color.red(formattedTargets[line]?.emails[0].email)
: color.red(formattedTargets[line])
);
}
});
} else {
console.log(color.green.bold("...pushed", formattedTargets.length));
}
return ids.upsertTargets;
};
const getTarget = async name => {
const extraQuery =
(argv.pages ? " actionPages {id name locale}" : "") +
(argv.users ? " users {email lastSigninAt role}" : "");
const query =
`
query GetTarget($name: String!) {
target(name:$name) {
... on PrivateTarget {
id name title processing {emailFrom,supporterConfirm,doiThankYou} config ` +
extraQuery +
`
}
}
}
`;
const data = await api(query, { name }, "GetTarget");
if (!data.target) throw new Error("can't find target " + name);
if (data.target.config) data.target.config = JSON.parse(data.target.config);
return data.target;
};
if (require.main === module) {
// this is run directly from the command line as in node xxx.js
if (!onGit()) {
console.warn(
color.italic.yellow(
"git integration disabled because the config folder isn't on git"
)
);
argv.git = false;
}
(async () => {
try {
const name = argv._[0];
if (argv.help) {
help(0);
}
if (!(argv.push || argv.pull || argv.publish || argv.digest)) {
summary(name);
console.error(
color.red("missing action, either --push --pull --publish --digest")
);
process.exit(1);
}
if (argv.digest) {
// await pullTarget(name, argv.file || name);
console.log(argv.file, name, argv.file || name);
await digestTarget(name, argv.file || name);
}
if (argv.push) {
if (argv.keep) {
argv.outdated = "keep";
}
if (
!"keep,delete,disable"
.split(",")
.includes(argv.outdated.toLowerCase())
) {
console.error(
color.red("invalid outdated, must be keep, delete or disable"),
argv.outdated,
"keep,delete,disable"
);
process.exit(1);
}
await pushTarget(name, argv.file || name);
console.log("push done");
}
if (argv.pull) {
await pullTarget(name, argv.file || name);
console.log("pull done");
}
if (argv.publish) {
await publishTarget(name, argv);
console.log("publish done");
}
} catch (e) {
console.error(e);
// Deal with the fact the chain failed
}
})();
} else {
//export a bunch
module.exports = {
getTarget,
pullTarget,
pushTarget,
readTarget,
getTwitter,
};
}