Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FeaturePanel: upload to WikimediaCommons 🎉 #492

Open
wants to merge 49 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e55f2a6
upload - add upload button
zbycz Nov 23, 2023
6b78b34
upload - parse uploads correctly, import wikiapi
zbycz Nov 30, 2023
afacb76
upload - description
zbycz Dec 2, 2023
d7c80d3
upload file + fetch user
zbycz Apr 18, 2024
f087402
parseHttpRequest
zbycz Apr 18, 2024
76fc919
exif
zbycz Apr 18, 2024
8ebd885
wikiapiUploadRequest()
zbycz Apr 18, 2024
3a4f867
upload works!!
zbycz Apr 18, 2024
ac3ea25
move
zbycz Apr 18, 2024
a284c23
custom mediawiki api
zbycz Apr 18, 2024
b627d52
get revision
zbycz Apr 19, 2024
68a8b5f
ok, this is not going to work. Lets try some libs again :)
zbycz Apr 19, 2024
4977bda
cr
zbycz May 15, 2024
6f3f82e
using formdata-node but getting HTML instead of JSON response... whaaat?
zbycz May 15, 2024
38d1ac2
ok - really use the lib now. Set-cookie used in csrf token request ju…
zbycz May 21, 2024
4733500
slight refactor + leave open missing csrftoken
zbycz Aug 20, 2024
781e89a
csrf token works !
zbycz Aug 21, 2024
4173a6c
upload to postbin works
zbycz Aug 21, 2024
0fb676c
upload works - but VERCEL has 4 MB limit...
zbycz Aug 21, 2024
7a7d98b
wip
zbycz Aug 21, 2024
f49b3c0
Revert "wip"
zbycz Aug 26, 2024
a1299ea
upload with S3 step works
zbycz Aug 28, 2024
4e6b04c
remove unneccesary deps
zbycz Aug 30, 2024
c68d4d5
claims workss!!!!!
zbycz Aug 30, 2024
208f7bd
fix test
zbycz Aug 30, 2024
0748477
fop
zbycz Aug 30, 2024
cec7fb9
advanced
zbycz Aug 30, 2024
4199744
add logging
zbycz Aug 31, 2024
1434089
Merge remote-tracking branch 'origin/master' into upload
zbycz Sep 22, 2024
e3b5aaf
add copyright claims + save to osm
zbycz Sep 23, 2024
7e91c59
quickFetchFeature, fix claims numeric, getLabel in osmApiAuth
zbycz Sep 23, 2024
2ebcb84
router refresh + lint
zbycz Sep 23, 2024
a78c217
fix upload response shape
zbycz Sep 23, 2024
f6e2685
intl console
zbycz Sep 23, 2024
b6506f7
isTitleAvail type
zbycz Sep 24, 2024
3424c0d
console
zbycz Sep 25, 2024
2edcdf5
FOP
zbycz Sep 25, 2024
bb900f3
fix heic + fopde
zbycz Sep 25, 2024
071f3fd
openclimbing url
zbycz Sep 25, 2024
fb6d9d8
Merge branch 'master' into upload
jvaclavik Sep 25, 2024
45c0e83
fix test
zbycz Sep 25, 2024
5b603e5
add OSMLink
zbycz Sep 26, 2024
7e26f72
Merge remote-tracking branch 'origin/master' into upload
zbycz Jan 5, 2025
2bdce77
env + comment
zbycz Jan 5, 2025
14da14a
Merge remote-tracking branch 'origin/master' into upload
zbycz Jan 5, 2025
cb2964a
exports
zbycz Jan 5, 2025
1578f2b
use {{Location}} + {{Object location}}
zbycz Jan 5, 2025
0b51374
less whitespaces
zbycz Jan 5, 2025
3b32b20
ENABLE_UPLOAD
zbycz Jan 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ NEXT_PUBLIC_API_KEY_GRAPHHOPPER=f189b841-6529-46c6-8a91-51f17477dcda
# Umami anylytics is used to track server load only. We don't want to track clicks in browser.
# optional, fill blank to disable
UMAMI_WEBSITE_ID=5e4d4917-9031-42f1-a26a-e71d7ab8e3fe

# Wikimedia Commons upload bot, experimental. Please leave blank.
NEXT_PUBLIC_ENABLE_UPLOAD=
OSMAPPBOT_PASSWORD=
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"dice-coefficient": "^2.1.1",
"image-size": "^1.1.1",
"isomorphic-unfetch": "^4.0.2",
"exifr": "^7.1.3",
"isomorphic-xml2js": "^0.1.3",
"js-cookie": "^3.0.5",
"js-md5": "^0.8.3",
Expand Down
45 changes: 45 additions & 0 deletions pages/api/_fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export const exampleUploadResponse = {
upload: {
filename: 'File_1.jpg',
result: 'Success',
imageinfo: {
url: 'https://upload.wikimedia.org/wikipedia/test/3/39/File_1.jpg',
html: '<p>A file with this name exists already, please check <strong><a class="mw-selflink selflink">File:File 1.jpg</a></strong> if you are not sure if you want to change it.\n</p>\n<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/w/index.php?title=Special:Upload&amp;wpDestFile=File_1.jpg" class="new" title="File:File 1.jpg">File:File 1.jpg</a> <div class="thumbcaption"></div></div></div>\n',
width: 474,
size: 26703,
bitdepth: 8,
mime: 'image/jpeg',
userid: 42588,
mediatype: 'BITMAP',
descriptionurl: 'https://test.wikipedia.org/wiki/File:File_1.jpg',
extmetadata: {
ObjectName: {
value: 'File 1',
hidden: '',
source: 'mediawiki-metadata',
},
DateTime: {
value: '2019-03-06 08:43:37',
hidden: '',
source: 'mediawiki-metadata',
},
// ...
},
comment: '',
commonmetadata: [],
descriptionshorturl: 'https://test.wikipedia.org/w/index.php?curid=0',
sha1: '2ffadd0da73fab31a50407671622fd6e5282d0cf',
parsedcomment: '',
metadata: [
{
name: 'MEDIAWIKI_EXIF_VERSION',
value: 2,
},
],
canonicaltitle: 'File:File 1.jpg',
user: 'Mansi29ag',
timestamp: '2019-03-06T08:43:37Z',
height: 296,
},
},
};
52 changes: 52 additions & 0 deletions pages/api/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { serverFetchOsmUser } from '../../src/server/osmApiAuthServer';
import {
fetchFeatureWithCenter,
fetchParentFeatures,
} from '../../src/services/osmApi';
import { intl, setIntl } from '../../src/services/intl';
import { getExifData } from '../../src/server/upload/getExifData';
import { uploadToWikimediaCommons } from '../../src/server/upload/uploadToWikimediaCommons';
import { getApiId } from '../../src/services/helpers';
import { File } from '../../src/server/upload/types';
import { setProjectForSSR } from '../../src/services/project';
import { fetchToFile } from '../../src/server/upload/fetchToFile';
import { OsmId } from '../../src/services/types';
import { isClimbingRoute } from '../../src/utils';
import { Feature } from '../../src/services/types';

// inspiration: https://commons.wikimedia.org/wiki/File:Drive_approaching_the_Grecian_Lodges_-_geograph.org.uk_-_5765640.jpg
// https://github.com/multichill/toollabs/blob/master/bot/commons/geograph_uploader.py
// TODO https://commons.wikimedia.org/wiki/Template:Geograph_from_structured_data

const getFeature = async (apiId: OsmId): Promise<Feature> => {
const feature = await fetchFeatureWithCenter(apiId);
if (isClimbingRoute(feature)) {
const parentFeatures = await fetchParentFeatures(feature.osmMeta);
return { ...feature, parentFeatures };
}
return feature;
};

export default async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { shortId, lang, url, filename } = JSON.parse(req.body);
setIntl({ lang, messages: [] });
setProjectForSSR(req);

const apiId = getApiId(shortId);
const feature = await getFeature(apiId);
console.log('intl', intl); // eslint-disable-line no-console
const user = await serverFetchOsmUser(req.cookies.osmAccessToken);

const filepath = await fetchToFile(url);
const { location, date } = await getExifData(filepath);
const file: File = { filepath, filename, location, date };
const out = await uploadToWikimediaCommons(user, feature, file, lang);

res.status(200).json(out);
} catch (err) {
console.error(err); // eslint-disable-line no-console
res.status(err.code ?? 400).send(String(err));
}
};
7 changes: 7 additions & 0 deletions src/components/FeaturePanel/FeaturePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ClimbingStructuredData } from './Climbing/ClimbingStructuredData';
import { isPublictransportRoute } from '../../utils';
import { Sockets } from './Sockets/Sockets';
import { ClimbingTypeBadge } from './Climbing/ClimbingTypeBadge';
import { UploadDialog } from './UploadDialog/UploadDialog';

const Flex = styled.div`
flex: 1;
Expand Down Expand Up @@ -80,6 +81,12 @@ export const FeaturePanel = ({ headingRef }: FeaturePanelProps) => {
</PanelSidePadding>

<Box mb={2}>
{process.env.NEXT_PUBLIC_ENABLE_UPLOAD && (
<PanelSidePadding>
<UploadDialog />
</PanelSidePadding>
)}

<FeatureImages />
</Box>

Expand Down
168 changes: 168 additions & 0 deletions src/components/FeaturePanel/UploadDialog/UploadDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React, { ChangeEvent, useState } from 'react';
import { Button, CircularProgress } from '@mui/material';
import { fetchJson, fetchText } from '../../../services/fetch';
import { useFeatureContext } from '../../utils/FeatureContext';
import { getShortId } from '../../../services/helpers';
import {
editOsmFeature,
loginAndfetchOsmUser,
} from '../../../services/osmApiAuth';
import { intl } from '../../../services/intl';
import { Feature } from '../../../services/types';
import { clearFeatureCache, quickFetchFeature } from '../../../services/osmApi';
import {
getNextWikimediaCommonsIndex,
getWikimediaCommonsKey,
} from '../Climbing/utils/photo';
import { useSnackbar } from '../../utils/SnackbarContext';
import { useRouter } from 'next/navigation';

const WIKIPEDIA_LIMIT = 100 * 1024 * 1024;
const BUCKET_URL = 'https://osmapp-upload-tmp.s3.amazonaws.com/';

// Vercel has limit 4.5 MB on payload size, so we have to upload to S3 first
const uploadToS3 = async (file: File) => {
const key = `${Math.random()}/${file.name}`;

const body = new FormData();
body.append('key', key);
body.append('file', file);

await fetchText(BUCKET_URL, { method: 'POST', body });

return `${BUCKET_URL}${key}`;
};

const submitToWikimediaCommons = async (
url: string,
filename: string,
feature: Feature,
) => {
const shortId = getShortId(feature.osmMeta);

return await fetchJson('/api/upload', {
method: 'POST',
body: JSON.stringify({ url, filename, shortId, lang: intl.lang }),
});
};

const performUploadWithLogin = async (
url: string,
filename: string,
feature: Feature,
) => {
try {
return await submitToWikimediaCommons(url, filename, feature);
} catch (e) {
if (e.code === '401') {
await loginAndfetchOsmUser();
return await submitToWikimediaCommons(url, filename, feature);
}
throw e;
}
};

const submitToOsm = async (feature: Feature, fileTitle: string) => {
clearFeatureCache(feature.osmMeta);
const freshFeature = await quickFetchFeature(feature.osmMeta);
const newPhotoIndex = getNextWikimediaCommonsIndex(freshFeature.tags);
await editOsmFeature(
freshFeature,
`Upload image ${fileTitle}`,
{
...freshFeature.tags,
[getWikimediaCommonsKey(newPhotoIndex)]: fileTitle,
},
false,
);

clearFeatureCache(feature.osmMeta);
};

const useGetHandleFileUpload = (
feature: Feature,
setUploading: React.Dispatch<React.SetStateAction<boolean>>,
setResetKey: React.Dispatch<React.SetStateAction<number>>,
) => {
const { showToast } = useSnackbar();
const router = useRouter();

return async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const filename = file.name;

if (!file) {
return;
}

if (file.size > WIKIPEDIA_LIMIT) {
alert('Maximum file size for Wikipedia is 100 MB.'); // eslint-disable-line no-alert
return;
}

try {
setUploading(true);
const url = await uploadToS3(file);
const wikiResponse = await performUploadWithLogin(url, filename, feature);
const osmResponse = await submitToOsm(feature, wikiResponse.title);

showToast('Image uploaded successfully', 'success');
router.refresh();
} finally {
setUploading(false);
setResetKey((key) => key + 1);
}
};
};

const UploadButton = () => {
const { feature } = useFeatureContext();
const [uploading, setUploading] = useState<boolean>(false);
const [resetKey, setResetKey] = useState<number>(0);

const handleFileUpload = useGetHandleFileUpload(
feature,
setUploading,
setResetKey,
);

return (
<>
<Button
component="label"
variant="contained"
color="primary"
// startIcon={<UploadFileIcon />}
style={{ marginBottom: '1rem' }}
disabled={uploading}
key={resetKey}
endIcon={uploading ? <CircularProgress /> : undefined}
>
Upload image
<input
type="file"
// accept="image/*"
hidden
onChange={handleFileUpload}
/>
</Button>
</>
);
};

export const UploadDialog = () => {
const { feature } = useFeatureContext();
const { osmMeta, skeleton } = feature;
const editEnabled = !skeleton;

return (
<>
{editEnabled && (
<>
<UploadButton />
<br />
</>
)}
</>
);
};
5 changes: 3 additions & 2 deletions src/helpers/featureLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ export const getTypeLabel = ({ layer, osmMeta, properties, schema }: Feature) =>
const getRefLabel = (feature: Feature) =>
feature.tags.ref ? `${getTypeLabel(feature)} ${feature.tags.ref}` : '';

const getName = ({ tags }: Feature) => tags[`name:${intl.lang}`] || tags.name;
export const getName = ({ tags }: Feature) =>
tags[`name:${intl.lang}`] || tags.name;

export const hasName = (feature: Feature) =>
feature.point || getName(feature) || getBuiltAddress(feature); // we dont want to show "No name" for point

export const getHumanPoiType = (feature: Feature) =>
hasName(feature) ? getTypeLabel(feature) : t('featurepanel.no_name');

const getLabelWithoutFallback = (feature: Feature) => {
export const getLabelWithoutFallback = (feature: Feature) => {
const { point, roundedCenter } = feature;
if (point) {
return roundedToDeg(roundedCenter);
Expand Down
Loading
Loading